Skip to content

Commit 4690010

Browse files
fix(safety): compile baked policy to code to resist binary tampering
Compile baked safety-profile policies into generated hash switches so the raw allow/deny rule strings are no longer embedded as a patchable YAML blob. Verification before merge: - `go test ./cmd/bake-safety-profile ./internal/safetyprofile ./internal/cmd` - `make lint` - `./build-safe.sh safety-profiles/agent-safe.yaml -o bin/gog-agent-safe-review` - `./build-safe.sh safety-profiles/readonly.yaml -o bin/gog-readonly-review` - runtime block checks for agent-safe and readonly baked binaries Co-authored-by: drewburchfield <drewburchfield@gmail.com>
1 parent 6fd8740 commit 4690010

11 files changed

Lines changed: 603 additions & 157 deletions

File tree

.golangci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ linters:
120120
- dupl
121121
- wsl_v5
122122
- tagliatelle
123+
- path: internal/safetyprofile/.*\.go
124+
linters:
125+
- err113
126+
- wrapcheck
127+
- wsl_v5
123128
- path: internal/googleauth/.*\.go
124129
linters:
125130
- tagliatelle

cmd/bake-safety-profile/main.go

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,111 @@ import (
55
"fmt"
66
"os"
77
"strconv"
8+
"strings"
89

9-
"github.com/steipete/gogcli/internal/cmd"
10+
"github.com/steipete/gogcli/internal/safetyprofile"
1011
)
1112

13+
const usage = `Usage: bake-safety-profile <profile.yaml> <output.go>` + "\n"
14+
1215
func main() {
13-
if len(os.Args) != 3 {
14-
_, _ = fmt.Fprintln(os.Stderr, "usage: bake-safety-profile <profile.yaml> <output.go>")
16+
args := os.Args[1:]
17+
if len(args) != 2 {
18+
_, _ = fmt.Fprint(os.Stderr, usage)
1519
os.Exit(2)
1620
}
1721

18-
raw, err := os.ReadFile(os.Args[1]) // #nosec G304 G703 -- build helper intentionally reads the requested profile path.
22+
raw, err := os.ReadFile(args[0]) // #nosec G304 G703 -- build helper intentionally reads the requested profile path.
1923
if err != nil {
2024
_, _ = fmt.Fprintf(os.Stderr, "read profile: %v\n", err)
2125
os.Exit(1)
2226
}
23-
if err := cmd.ValidateSafetyProfile(string(raw)); err != nil {
27+
28+
profile, err := safetyprofile.Parse(string(raw))
29+
if err != nil {
2430
_, _ = fmt.Fprintf(os.Stderr, "parse profile: %v\n", err)
2531
os.Exit(1)
2632
}
2733

34+
out := generate(profile)
35+
if err := os.WriteFile(args[1], out, 0o600); err != nil { // #nosec G306 G703 -- build helper intentionally writes the requested generated Go path.
36+
_, _ = fmt.Fprintf(os.Stderr, "write output: %v\n", err)
37+
os.Exit(1)
38+
}
39+
}
40+
41+
// generate emits a Go file that resolves the bakedSafety* package-level
42+
// functions for safety_profile builds. Allow and deny rules are encoded as
43+
// FNV-64a hashes in switch statements so that the rule set itself is no
44+
// longer a contiguous, patchable string in the binary.
45+
func generate(profile *safetyprofile.Profile) []byte {
2846
var out bytes.Buffer
47+
hasAllowRules := profile.AllowAll || len(profile.AllowRules) > 0
48+
2949
out.WriteString("// Code generated by cmd/bake-safety-profile; DO NOT EDIT.\n")
50+
commentName := strings.ReplaceAll(strings.ReplaceAll(profile.Name, "\n", " "), "\r", " ")
51+
fmt.Fprintf(&out, "// Profile: %s (%d allow, %d deny, allow-all=%t)\n", commentName, len(profile.AllowRules), len(profile.DenyRules), profile.AllowAll)
52+
out.WriteString("// Hash: FNV-64a over dotted command paths.\n")
3053
out.WriteString("//go:build safety_profile\n\n")
3154
out.WriteString("package cmd\n\n")
32-
out.WriteString("var bakedSafetyProfileYAML = ")
33-
out.WriteString(strconv.Quote(string(raw)))
55+
56+
out.WriteString("const bakedSafetyProfileNameConst = ")
57+
out.WriteString(strconv.Quote(profile.Name))
58+
out.WriteString("\n\n")
59+
60+
out.WriteString("func bakedSafetyEnabled() bool { return true }\n")
61+
out.WriteString("func bakedSafetyProfileName() string { return bakedSafetyProfileNameConst }\n")
62+
fmt.Fprintf(&out, "func bakedSafetyHasAllowRules() bool { return %t }\n\n", hasAllowRules)
63+
64+
writeMatcher(&out, "bakedSafetyAllowMatch", profile.AllowRules, profile.AllowAll)
3465
out.WriteString("\n")
66+
writeMatcher(&out, "bakedSafetyDenyMatch", profile.DenyRules, false)
3567

36-
if err := os.WriteFile(os.Args[2], out.Bytes(), 0o600); err != nil { // #nosec G306 G703 -- build helper intentionally writes the requested generated Go path.
37-
_, _ = fmt.Fprintf(os.Stderr, "write output: %v\n", err)
38-
os.Exit(1)
68+
return out.Bytes()
69+
}
70+
71+
func writeMatcher(out *bytes.Buffer, name string, rules []string, matchAll bool) {
72+
fmt.Fprintf(out, "func %s(path []string) bool {\n", name)
73+
if matchAll {
74+
out.WriteString("\treturn true\n}\n")
75+
return
76+
}
77+
if len(rules) == 0 {
78+
out.WriteString("\treturn false\n}\n")
79+
return
80+
}
81+
82+
out.WriteString("\tif len(path) == 0 {\n\t\treturn false\n\t}\n")
83+
out.WriteString("\tfor i := 1; i <= len(path); i++ {\n")
84+
out.WriteString("\t\tswitch bakedSafetyHashPath(path[:i]) {\n")
85+
out.WriteString("\t\tcase ")
86+
87+
cases := make([]string, 0, len(rules))
88+
seen := make(map[uint64]string, len(rules))
89+
for _, rule := range rules {
90+
h := safetyprofile.HashRule(rule)
91+
if existing, dup := seen[h]; dup {
92+
fmt.Fprintf(os.Stderr, "bake-safety-profile: hash collision between %q and %q (FNV-64a=%#x); pick a different name or extend the hash\n", existing, rule, h)
93+
os.Exit(1)
94+
}
95+
seen[h] = rule
96+
cases = append(cases, fmt.Sprintf("0x%016x", h))
97+
}
98+
99+
const perLine = 4
100+
for i, c := range cases {
101+
out.WriteString(c)
102+
if i == len(cases)-1 {
103+
break
104+
}
105+
out.WriteString(",")
106+
if (i+1)%perLine == 0 {
107+
out.WriteString("\n\t\t\t")
108+
} else {
109+
out.WriteString(" ")
110+
}
39111
}
112+
out.WriteString(":\n\t\t\treturn true\n")
113+
out.WriteString("\t\t}\n\t}\n")
114+
out.WriteString("\treturn false\n}\n")
40115
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"go/parser"
7+
"go/token"
8+
"strings"
9+
"testing"
10+
11+
"github.com/steipete/gogcli/internal/safetyprofile"
12+
)
13+
14+
func TestGenerateProducesParseableGoWithExpectedHashes(t *testing.T) {
15+
profile := &safetyprofile.Profile{
16+
Name: "test",
17+
AllowAll: false,
18+
AllowRules: []string{"version", "gmail.search", "gmail.drafts.create"},
19+
DenyRules: []string{"gmail.send", "gmail.drafts.send"},
20+
}
21+
22+
out := generate(profile)
23+
24+
if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil {
25+
t.Fatalf("generated code does not parse as Go:\n%s\n\nerror: %v", out, err)
26+
}
27+
28+
want := []string{
29+
`//go:build safety_profile`,
30+
`package cmd`,
31+
`const bakedSafetyProfileNameConst = "test"`,
32+
`func bakedSafetyEnabled() bool { return true }`,
33+
`func bakedSafetyHasAllowRules() bool { return true }`,
34+
}
35+
for _, line := range want {
36+
if !bytes.Contains(out, []byte(line)) {
37+
t.Fatalf("generated output missing %q\n\nfull output:\n%s", line, out)
38+
}
39+
}
40+
41+
for _, rule := range profile.AllowRules {
42+
hex := fmt.Sprintf("0x%016x", safetyprofile.HashRule(rule))
43+
if !bytes.Contains(out, []byte(hex)) {
44+
t.Fatalf("expected allow hash %s for rule %q in output", hex, rule)
45+
}
46+
}
47+
for _, rule := range profile.DenyRules {
48+
hex := fmt.Sprintf("0x%016x", safetyprofile.HashRule(rule))
49+
if !bytes.Contains(out, []byte(hex)) {
50+
t.Fatalf("expected deny hash %s for rule %q in output", hex, rule)
51+
}
52+
}
53+
54+
for _, rule := range profile.AllowRules {
55+
if bytes.Contains(out, []byte(fmt.Sprintf("%q", rule))) {
56+
t.Fatalf("rule string %q must not appear in generated output", rule)
57+
}
58+
}
59+
for _, rule := range profile.DenyRules {
60+
if bytes.Contains(out, []byte(fmt.Sprintf("%q", rule))) {
61+
t.Fatalf("rule string %q must not appear in generated output", rule)
62+
}
63+
}
64+
}
65+
66+
func TestGenerateSanitizesProfileNameInComment(t *testing.T) {
67+
profile := &safetyprofile.Profile{
68+
Name: "bad\nname\rwith-controls",
69+
AllowAll: true,
70+
DenyRules: []string{},
71+
}
72+
out := generate(profile)
73+
74+
if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil {
75+
t.Fatalf("generated code with control chars in name does not parse:\n%s\n\nerror: %v", out, err)
76+
}
77+
if bytes.Contains(out, []byte("\nname\r")) || bytes.Contains(out, []byte("\nname\n")) {
78+
t.Fatalf("comment header leaked control chars from profile name:\n%s", out)
79+
}
80+
}
81+
82+
func TestGenerateAllowAllEmitsConstantTrue(t *testing.T) {
83+
profile := &safetyprofile.Profile{
84+
Name: "full",
85+
AllowAll: true,
86+
DenyRules: []string{},
87+
}
88+
out := string(generate(profile))
89+
90+
allowFn := extractFunc(t, out, "bakedSafetyAllowMatch")
91+
if !strings.Contains(allowFn, "return true") || strings.Contains(allowFn, "switch ") {
92+
t.Fatalf("AllowAll allow matcher should be `return true` only, got:\n%s", allowFn)
93+
}
94+
95+
denyFn := extractFunc(t, out, "bakedSafetyDenyMatch")
96+
if !strings.Contains(denyFn, "return false") {
97+
t.Fatalf("empty deny matcher should `return false`, got:\n%s", denyFn)
98+
}
99+
}
100+
101+
func TestGenerateEmptyAllowEmitsConstantFalse(t *testing.T) {
102+
profile := &safetyprofile.Profile{
103+
Name: "deny-only",
104+
AllowAll: false,
105+
DenyRules: []string{"gmail.send"},
106+
}
107+
out := string(generate(profile))
108+
109+
if !strings.Contains(out, "func bakedSafetyHasAllowRules() bool { return false }") {
110+
t.Fatalf("expected hasAllowRules=false for deny-only profile, got:\n%s", out)
111+
}
112+
allowFn := extractFunc(t, out, "bakedSafetyAllowMatch")
113+
if !strings.Contains(allowFn, "return false") || strings.Contains(allowFn, "switch ") {
114+
t.Fatalf("empty allow matcher should be `return false` only, got:\n%s", allowFn)
115+
}
116+
}
117+
118+
func extractFunc(t *testing.T, src, name string) string {
119+
t.Helper()
120+
start := strings.Index(src, "func "+name+"(")
121+
if start < 0 {
122+
t.Fatalf("function %s not found in:\n%s", name, src)
123+
}
124+
depth := 0
125+
for i := start; i < len(src); i++ {
126+
switch src[i] {
127+
case '{':
128+
depth++
129+
case '}':
130+
depth--
131+
if depth == 0 {
132+
return src[start : i+1]
133+
}
134+
}
135+
}
136+
t.Fatalf("function %s has unbalanced braces", name)
137+
return ""
138+
}

docs/safety-profiles.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ bin/gog-readonly --enable-commands gmail.send gmail send \
6767
The command still fails because the baked policy is checked before runtime
6868
allowlists.
6969

70+
## Tamper Resistance
71+
72+
The generator emits the allow and deny rule sets as `switch` statements on the
73+
FNV-64a hash of each dotted command path, not as raw YAML. The compiled rule
74+
table never contains the rule strings themselves, so to re-enable a blocked
75+
command an attacker has to patch compiled machine code rather than flip ASCII
76+
bytes in a YAML blob; the cost goes from a one-line `sed` invocation to
77+
disassembly-level work.
78+
79+
Note that command names may still appear in the binary from unrelated metadata
80+
(API URLs, error message format strings, Kong help text). What this hardening
81+
guarantees is that the rule set itself is no longer a contiguous, patchable
82+
string. The profile name (e.g. `agent-safe`) is also embedded as a constant so
83+
error messages can reference it.
84+
7085
## Preset Profiles
7186

7287
`safety-profiles/agent-safe.yaml`

0 commit comments

Comments
 (0)