@synopackageland/cli 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +6 -3
- package/assets/go/route-audit/main.go +376 -45
- package/dist/standalone/api-description.js +3 -0
- package/dist/standalone/build.js +13 -2
- package/dist/standalone/manifest.js +12 -4
- package/dist/standalone/route-audit.d.ts +9 -3
- package/dist/standalone/route-audit.js +6 -2
- package/package.json +1 -1
- package/skills/SKILL.md +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.0 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
- Audit the target Linux import graphs of configured Go binaries rather than
|
|
6
|
+
recursively scanning unshipped development commands.
|
|
7
|
+
- Accept fixed, permission-declared `api.HandleHTTP` routes and package-private
|
|
8
|
+
`ipc.ServeUnix` handler graphs with SDK 0.33.0. Reject direct listeners and
|
|
9
|
+
unregistered handlers even when an IPC handler is reused.
|
|
10
|
+
- Bind the raw HTTP prefix inventory and package IPC identity into each Go
|
|
11
|
+
binary. Repeat source validation after UI hooks. Retain executor 0.32.0 bytes
|
|
12
|
+
and templates 0.32.0.
|
|
13
|
+
|
|
3
14
|
## 0.34.0 (2026-10-29)
|
|
4
15
|
|
|
5
16
|
- Enforce explicit App entry and reject undeclared App methods, dynamic route
|
package/README.md
CHANGED
|
@@ -74,15 +74,18 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
|
|
|
74
74
|
packages. Private mirrors can use explicitly configured Go authentication and
|
|
75
75
|
checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
|
|
76
76
|
|
|
77
|
-
CLI 0.
|
|
77
|
+
CLI 0.35.0 uses templates ~0.32.0, whose default SDK pin remains 0.32.0. Compose import and SDK defaults
|
|
78
78
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
79
79
|
Every App method must be declared in the permissions table. Dynamic names and
|
|
80
|
-
custom HTTP dispatchers are rejected
|
|
80
|
+
custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
|
|
81
|
+
`api.HandleHTTP` prefixes for authorized raw HTTP and `ipc.ServeUnix` for private
|
|
82
|
+
package Unix sockets. The audit follows each shipped Linux binary's import graph;
|
|
83
|
+
unimported development commands are excluded. Use
|
|
81
84
|
`identity_gateway.upstream_auth_paths` for client API paths that use upstream
|
|
82
85
|
authentication. See [App entry and registration](docs/app-entry.md).
|
|
83
86
|
|
|
84
87
|
`init` and `add` read their templates from the locally installed
|
|
85
|
-
`@synopackageland/templates`. CLI 0.
|
|
88
|
+
`@synopackageland/templates`. CLI 0.35.0 depends on `~0.32.0`: npm installation or
|
|
86
89
|
update can select the latest templates patch in the 0.32 minor line. Templates
|
|
87
90
|
patches need only a templates release; update the npm installation to receive them.
|
|
88
91
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
@@ -2,26 +2,302 @@
|
|
|
2
2
|
package main
|
|
3
3
|
|
|
4
4
|
import (
|
|
5
|
+
"bytes"
|
|
5
6
|
"encoding/json"
|
|
6
7
|
"fmt"
|
|
7
8
|
"go/ast"
|
|
8
9
|
"go/parser"
|
|
9
10
|
"go/token"
|
|
11
|
+
"io"
|
|
10
12
|
"os"
|
|
13
|
+
"os/exec"
|
|
11
14
|
"path/filepath"
|
|
15
|
+
"regexp"
|
|
16
|
+
"sort"
|
|
12
17
|
"strconv"
|
|
13
18
|
"strings"
|
|
14
19
|
)
|
|
15
20
|
|
|
16
21
|
type input struct {
|
|
17
22
|
Dir string
|
|
23
|
+
Roots []string
|
|
24
|
+
Arches []string
|
|
18
25
|
Permissions map[string]string
|
|
19
26
|
SDK map[string]map[string]string
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
type result struct {
|
|
23
|
-
Problems
|
|
24
|
-
EntryGate
|
|
30
|
+
Problems []string `json:"problems"`
|
|
31
|
+
EntryGate bool `json:"entryGate"`
|
|
32
|
+
HTTPRoutes []httpRoute `json:"httpRoutes"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type httpRoute struct {
|
|
36
|
+
Prefix string `json:"prefix"`
|
|
37
|
+
Method string `json:"method"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sdkModule = "github.com/synopackageland/syno-pkg-land-sdk"
|
|
41
|
+
|
|
42
|
+
// Resolve target build constraints without running any application code.
|
|
43
|
+
func shippedFiles(in input) (map[string]string, error) {
|
|
44
|
+
files := map[string]string{}
|
|
45
|
+
if _, err := os.Stat(filepath.Join(in.Dir, "go.mod")); os.IsNotExist(err) {
|
|
46
|
+
matches, err := filepath.Glob(filepath.Join(in.Dir, "*.go"))
|
|
47
|
+
for _, path := range matches {
|
|
48
|
+
if !strings.HasSuffix(path, "_test.go") {
|
|
49
|
+
files[path] = "fixture"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return files, err
|
|
53
|
+
}
|
|
54
|
+
roots, arches := in.Roots, in.Arches
|
|
55
|
+
if len(roots) == 0 {
|
|
56
|
+
roots = []string{"."}
|
|
57
|
+
}
|
|
58
|
+
if len(arches) == 0 {
|
|
59
|
+
arches = []string{"amd64", "arm64", "arm"}
|
|
60
|
+
}
|
|
61
|
+
for _, arch := range arches {
|
|
62
|
+
cmd := exec.Command("go", append([]string{"list", "-e", "-mod=readonly", "-deps", "-json"}, roots...)...)
|
|
63
|
+
cmd.Dir = in.Dir
|
|
64
|
+
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+arch, "GOARM=7", "CGO_ENABLED=0", "GOWORK=off", "GOFLAGS=")
|
|
65
|
+
raw, err := cmd.Output()
|
|
66
|
+
if err != nil {
|
|
67
|
+
return nil, fmt.Errorf("cannot resolve shipped Go import graph")
|
|
68
|
+
}
|
|
69
|
+
d := json.NewDecoder(bytes.NewReader(raw))
|
|
70
|
+
for {
|
|
71
|
+
var p struct {
|
|
72
|
+
Dir, ImportPath string
|
|
73
|
+
GoFiles []string
|
|
74
|
+
Standard bool
|
|
75
|
+
DepOnly bool
|
|
76
|
+
Error *struct{ Err string }
|
|
77
|
+
Module *struct {
|
|
78
|
+
Main bool
|
|
79
|
+
Replace *struct{ Dir string }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
err := d.Decode(&p)
|
|
83
|
+
if err == io.EOF {
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
if err != nil {
|
|
87
|
+
return nil, err
|
|
88
|
+
}
|
|
89
|
+
if !p.DepOnly && p.Error != nil {
|
|
90
|
+
return nil, fmt.Errorf("cannot resolve a configured shipped Go root")
|
|
91
|
+
}
|
|
92
|
+
if p.Standard || p.ImportPath == sdkModule || strings.HasPrefix(p.ImportPath, sdkModule+"/") {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if p.Module == nil || !p.Module.Main && p.Module.Replace == nil {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
for _, name := range p.GoFiles {
|
|
99
|
+
files[filepath.Join(p.Dir, name)] = p.ImportPath
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if len(files) == 0 {
|
|
104
|
+
return nil, fmt.Errorf("shipped Go roots contain no auditable source")
|
|
105
|
+
}
|
|
106
|
+
return files, nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func stringLiteral(expr ast.Expr) string {
|
|
110
|
+
if v, ok := expr.(*ast.BasicLit); ok && v.Kind == token.STRING {
|
|
111
|
+
s, _ := strconv.Unquote(v.Value)
|
|
112
|
+
return s
|
|
113
|
+
}
|
|
114
|
+
return ""
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type source struct {
|
|
118
|
+
file *ast.File
|
|
119
|
+
path, pkg string
|
|
120
|
+
imports map[string]string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Follow the handler argument through local declarations and factories. A
|
|
124
|
+
// boundary never exempts an entire directory, file, or any listener primitive.
|
|
125
|
+
func boundaryHandlers(sources []source) map[ast.Node]bool {
|
|
126
|
+
decls := map[string][]ast.Node{}
|
|
127
|
+
owners := map[ast.Node]source{}
|
|
128
|
+
for _, s := range sources {
|
|
129
|
+
ast.Inspect(s.file, func(n ast.Node) bool {
|
|
130
|
+
if n != nil {
|
|
131
|
+
owners[n] = s
|
|
132
|
+
}
|
|
133
|
+
return true
|
|
134
|
+
})
|
|
135
|
+
for _, decl := range s.file.Decls {
|
|
136
|
+
switch d := decl.(type) {
|
|
137
|
+
case *ast.FuncDecl:
|
|
138
|
+
key := s.pkg + "." + d.Name.Name
|
|
139
|
+
if d.Recv != nil {
|
|
140
|
+
t := d.Recv.List[0].Type
|
|
141
|
+
if v, ok := t.(*ast.StarExpr); ok {
|
|
142
|
+
t = v.X
|
|
143
|
+
}
|
|
144
|
+
if v, ok := t.(*ast.Ident); ok {
|
|
145
|
+
key = s.pkg + "." + v.Name
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
decls[key] = append(decls[key], d)
|
|
149
|
+
case *ast.GenDecl:
|
|
150
|
+
for _, spec := range d.Specs {
|
|
151
|
+
switch v := spec.(type) {
|
|
152
|
+
case *ast.TypeSpec:
|
|
153
|
+
decls[s.pkg+"."+v.Name.Name] = append(decls[s.pkg+"."+v.Name.Name], v)
|
|
154
|
+
case *ast.ValueSpec:
|
|
155
|
+
for _, name := range v.Names {
|
|
156
|
+
decls[s.pkg+"."+name.Name] = append(decls[s.pkg+"."+name.Name], v)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
safe, visited := map[ast.Node]bool{}, map[ast.Node]bool{}
|
|
164
|
+
var mark func(ast.Node)
|
|
165
|
+
mark = func(root ast.Node) {
|
|
166
|
+
if root == nil || visited[root] {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
visited[root] = true
|
|
170
|
+
s := owners[root]
|
|
171
|
+
ast.Inspect(root, func(n ast.Node) bool {
|
|
172
|
+
if n == nil {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
safe[n] = true
|
|
176
|
+
key := ""
|
|
177
|
+
switch v := n.(type) {
|
|
178
|
+
case *ast.Ident:
|
|
179
|
+
key = s.pkg + "." + v.Name
|
|
180
|
+
if v.Obj != nil {
|
|
181
|
+
if d, ok := v.Obj.Decl.(ast.Node); ok {
|
|
182
|
+
mark(d)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
case *ast.SelectorExpr:
|
|
186
|
+
if id, ok := v.X.(*ast.Ident); ok {
|
|
187
|
+
key = s.imports[id.Name] + "." + v.Sel.Name
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for _, decl := range decls[key] {
|
|
191
|
+
mark(decl)
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
for _, s := range sources {
|
|
197
|
+
ast.Inspect(s.file, func(n ast.Node) bool {
|
|
198
|
+
call, ok := n.(*ast.CallExpr)
|
|
199
|
+
if !ok || len(call.Args) != 3 {
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
203
|
+
if !ok {
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
id, ok := sel.X.(*ast.Ident)
|
|
207
|
+
if ok && (s.imports[id.Name] == sdkModule+"/api" && sel.Sel.Name == "HandleHTTP" ||
|
|
208
|
+
s.imports[id.Name] == sdkModule+"/ipc" && sel.Sel.Name == "ServeUnix") {
|
|
209
|
+
mark(call.Args[2])
|
|
210
|
+
}
|
|
211
|
+
return true
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return safe
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A handler admitted to a private/raw boundary cannot also be installed as
|
|
218
|
+
// the CGI root. SDK server construction ends this search because it gates
|
|
219
|
+
// its registered routes before invoking their handlers.
|
|
220
|
+
func reusesBoundary(expr ast.Node, owner source, sources []source, safe map[ast.Node]bool, seen map[ast.Node]bool) bool {
|
|
221
|
+
if expr == nil || seen[expr] {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
seen[expr] = true
|
|
225
|
+
imports := owner.imports
|
|
226
|
+
if call, ok := expr.(*ast.CallExpr); ok {
|
|
227
|
+
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
|
|
228
|
+
if id, ok := sel.X.(*ast.Ident); ok && imports[id.Name] == sdkModule+"/api" {
|
|
229
|
+
switch sel.Sel.Name {
|
|
230
|
+
case "NewServerWithPermissions":
|
|
231
|
+
return false
|
|
232
|
+
case "WithRequestDeadline", "WithDescription":
|
|
233
|
+
return len(call.Args) > 0 && reusesBoundary(call.Args[0], owner, sources, safe, seen)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
found := false
|
|
239
|
+
ast.Inspect(expr, func(n ast.Node) bool {
|
|
240
|
+
if n == nil || found {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
if n != expr {
|
|
244
|
+
if _, ok := n.(*ast.CallExpr); ok {
|
|
245
|
+
found = reusesBoundary(n, owner, sources, safe, seen)
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
targetPackage, targetName := "", ""
|
|
250
|
+
if id, ok := n.(*ast.Ident); ok {
|
|
251
|
+
if id.Obj != nil {
|
|
252
|
+
if decl, ok := id.Obj.Decl.(ast.Node); ok {
|
|
253
|
+
found = safe[decl] || reusesBoundary(decl, owner, sources, safe, seen)
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
targetPackage, targetName = owner.pkg, id.Name
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if selector, ok := n.(*ast.SelectorExpr); ok {
|
|
260
|
+
if pkg, ok := selector.X.(*ast.Ident); ok {
|
|
261
|
+
targetPackage, targetName = imports[pkg.Name], selector.Sel.Name
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if targetPackage != "" {
|
|
265
|
+
for _, candidate := range sources {
|
|
266
|
+
if candidate.pkg != targetPackage {
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
for _, decl := range candidate.file.Decls {
|
|
270
|
+
var target ast.Node
|
|
271
|
+
switch d := decl.(type) {
|
|
272
|
+
case *ast.FuncDecl:
|
|
273
|
+
if d.Recv == nil && d.Name.Name == targetName {
|
|
274
|
+
target = d
|
|
275
|
+
}
|
|
276
|
+
case *ast.GenDecl:
|
|
277
|
+
for _, spec := range d.Specs {
|
|
278
|
+
switch v := spec.(type) {
|
|
279
|
+
case *ast.TypeSpec:
|
|
280
|
+
if v.Name.Name == targetName {
|
|
281
|
+
target = v
|
|
282
|
+
}
|
|
283
|
+
case *ast.ValueSpec:
|
|
284
|
+
for _, name := range v.Names {
|
|
285
|
+
if name.Name == targetName {
|
|
286
|
+
target = v
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if target != nil && (safe[target] || reusesBoundary(target, candidate, sources, safe, seen)) {
|
|
293
|
+
found = true
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return !found
|
|
299
|
+
})
|
|
300
|
+
return found
|
|
25
301
|
}
|
|
26
302
|
|
|
27
303
|
func main() {
|
|
@@ -30,7 +306,7 @@ func main() {
|
|
|
30
306
|
fmt.Fprintln(os.Stderr, err)
|
|
31
307
|
os.Exit(1)
|
|
32
308
|
}
|
|
33
|
-
out := result{Problems: []string{}}
|
|
309
|
+
out := result{Problems: []string{}, HTTPRoutes: []httpRoute{}}
|
|
34
310
|
sdk := map[string]bool{}
|
|
35
311
|
for _, group := range in.SDK {
|
|
36
312
|
for method := range group {
|
|
@@ -38,50 +314,55 @@ func main() {
|
|
|
38
314
|
}
|
|
39
315
|
}
|
|
40
316
|
set := token.NewFileSet()
|
|
41
|
-
err :=
|
|
317
|
+
files, err := shippedFiles(in)
|
|
318
|
+
if err != nil {
|
|
319
|
+
fmt.Fprintln(os.Stderr, err)
|
|
320
|
+
os.Exit(1)
|
|
321
|
+
}
|
|
322
|
+
var sources []source
|
|
323
|
+
for path, pkg := range files {
|
|
324
|
+
file, err := parser.ParseFile(set, path, nil, 0)
|
|
42
325
|
if err != nil {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if d.IsDir() {
|
|
46
|
-
if path != in.Dir && (strings.HasPrefix(d.Name(), ".") || d.Name() == "vendor" || d.Name() == "node_modules") {
|
|
47
|
-
return filepath.SkipDir
|
|
48
|
-
}
|
|
49
|
-
return nil
|
|
326
|
+
fmt.Fprintln(os.Stderr, "invalid shipped Go source")
|
|
327
|
+
os.Exit(1)
|
|
50
328
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
329
|
+
imports := map[string]string{}
|
|
330
|
+
for _, spec := range file.Imports {
|
|
331
|
+
module, _ := strconv.Unquote(spec.Path.Value)
|
|
332
|
+
alias := filepath.Base(module)
|
|
333
|
+
if spec.Name != nil {
|
|
334
|
+
alias = spec.Name.Name
|
|
57
335
|
}
|
|
336
|
+
imports[alias] = module
|
|
58
337
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
338
|
+
sources = append(sources, source{file, path, pkg, imports})
|
|
339
|
+
}
|
|
340
|
+
safe := boundaryHandlers(sources)
|
|
341
|
+
for _, src := range sources {
|
|
342
|
+
path, file, imports := src.path, src.file, src.imports
|
|
62
343
|
shown, _ := filepath.Rel(in.Dir, path)
|
|
63
344
|
problem := func(pos token.Pos, message string) {
|
|
64
345
|
out.Problems = append(out.Problems, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(shown), set.Position(pos).Line, message))
|
|
65
346
|
}
|
|
66
|
-
|
|
347
|
+
d, err := os.Lstat(path)
|
|
348
|
+
if err != nil || d.Mode()&os.ModeSymlink != 0 {
|
|
67
349
|
problem(token.NoPos, "Go source symlinks cannot be audited")
|
|
68
|
-
|
|
350
|
+
continue
|
|
69
351
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
352
|
+
for dir := filepath.Dir(path); dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
|
353
|
+
info, err := os.Lstat(dir)
|
|
354
|
+
if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
355
|
+
problem(token.NoPos, "Go source directories cannot be symlinks")
|
|
356
|
+
break
|
|
357
|
+
}
|
|
358
|
+
if dir == in.Dir {
|
|
359
|
+
break
|
|
360
|
+
}
|
|
73
361
|
}
|
|
74
|
-
imports := map[string]string{}
|
|
75
362
|
for _, spec := range file.Imports {
|
|
76
|
-
|
|
77
|
-
alias := filepath.Base(module)
|
|
78
|
-
if spec.Name != nil {
|
|
79
|
-
alias = spec.Name.Name
|
|
80
|
-
}
|
|
81
|
-
if alias == "." {
|
|
363
|
+
if spec.Name != nil && spec.Name.Name == "." {
|
|
82
364
|
problem(spec.Pos(), "dot imports cannot be audited")
|
|
83
365
|
}
|
|
84
|
-
imports[alias] = module
|
|
85
366
|
}
|
|
86
367
|
constants := map[string]string{}
|
|
87
368
|
direct := map[*ast.SelectorExpr]bool{}
|
|
@@ -108,25 +389,36 @@ func main() {
|
|
|
108
389
|
}
|
|
109
390
|
}
|
|
110
391
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
111
|
-
if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" {
|
|
112
|
-
problem(fn.Pos(), "
|
|
392
|
+
if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" && !safe[n] {
|
|
393
|
+
problem(fn.Pos(), "unregistered ServeHTTP; use api.HandleHTTP or ipc.ServeUnix")
|
|
113
394
|
}
|
|
114
395
|
selector, ok := n.(*ast.SelectorExpr)
|
|
115
396
|
if !ok {
|
|
116
397
|
return true
|
|
117
398
|
}
|
|
399
|
+
if selector.Sel.Name == "ServeHTTP" && !safe[n] {
|
|
400
|
+
problem(selector.Pos(), "unregistered ServeHTTP dispatch")
|
|
401
|
+
}
|
|
118
402
|
pkg, ok := selector.X.(*ast.Ident)
|
|
119
403
|
if !ok {
|
|
120
404
|
return true
|
|
121
405
|
}
|
|
122
406
|
module := imports[pkg.Name]
|
|
123
407
|
name := selector.Sel.Name
|
|
408
|
+
if module == "net" && (strings.HasPrefix(name, "Listen") || name == "FileListener" || name == "FilePacketConn") ||
|
|
409
|
+
module == "crypto/tls" && name == "Listen" ||
|
|
410
|
+
(module == "syscall" || module == "golang.org/x/sys/unix") && (name == "Bind" || name == "Listen" || name == "Socket") {
|
|
411
|
+
problem(selector.Pos(), "custom listener outside SDK boundaries: "+name)
|
|
412
|
+
}
|
|
413
|
+
if module == sdkModule+"/ipc" && name == "ServeUnix" && !direct[selector] {
|
|
414
|
+
problem(selector.Pos(), "IPC constructor aliases cannot be audited")
|
|
415
|
+
}
|
|
124
416
|
if module == "github.com/synopackageland/syno-pkg-land-sdk/idgateway" && (name == "NewFront" || name == "Build" || name == "Run") {
|
|
125
417
|
problem(selector.Pos(), "gateway listeners must use identity_gateway in the manifest; upstream_auth_paths never exempts SDK RPC or custom HTTP handlers")
|
|
126
418
|
}
|
|
127
419
|
if module == "github.com/synopackageland/syno-pkg-land-sdk/api" {
|
|
128
420
|
switch name {
|
|
129
|
-
case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
|
|
421
|
+
case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
|
|
130
422
|
if !direct[selector] {
|
|
131
423
|
problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
|
|
132
424
|
}
|
|
@@ -134,8 +426,20 @@ func main() {
|
|
|
134
426
|
}
|
|
135
427
|
if module == "net/http" {
|
|
136
428
|
switch name {
|
|
137
|
-
case "Handle", "HandleFunc", "
|
|
429
|
+
case "Handle", "HandleFunc", "Server", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
|
|
138
430
|
problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
|
|
431
|
+
case "HandlerFunc", "NewServeMux", "ServeMux", "FileServer", "FileServerFS", "StripPrefix", "TimeoutHandler", "NotFoundHandler", "RedirectHandler":
|
|
432
|
+
if !safe[n] {
|
|
433
|
+
problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if module == "net/http/httputil" && (name == "ReverseProxy" || name == "NewSingleHostReverseProxy") && !safe[n] {
|
|
438
|
+
problem(selector.Pos(), "HTTP proxy outside the SDK registry")
|
|
439
|
+
}
|
|
440
|
+
if module == "net/http/cgi" && name == "Serve" {
|
|
441
|
+
if !direct[selector] {
|
|
442
|
+
problem(selector.Pos(), "CGI constructor aliases cannot be audited")
|
|
139
443
|
}
|
|
140
444
|
}
|
|
141
445
|
return true
|
|
@@ -154,6 +458,9 @@ func main() {
|
|
|
154
458
|
return true
|
|
155
459
|
}
|
|
156
460
|
module := imports[pkg.Name]
|
|
461
|
+
if module == "net/http/cgi" && selector.Sel.Name == "Serve" && len(call.Args) > 0 && reusesBoundary(call.Args[0], src, sources, safe, map[ast.Node]bool{}) {
|
|
462
|
+
problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
|
|
463
|
+
}
|
|
157
464
|
if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
|
|
158
465
|
out.EntryGate = true
|
|
159
466
|
}
|
|
@@ -161,13 +468,17 @@ func main() {
|
|
|
161
468
|
return true
|
|
162
469
|
}
|
|
163
470
|
switch selector.Sel.Name {
|
|
164
|
-
case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
|
|
471
|
+
case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch", "HandleHTTP":
|
|
165
472
|
default:
|
|
166
473
|
return true
|
|
167
474
|
}
|
|
168
475
|
method := ""
|
|
169
|
-
|
|
170
|
-
|
|
476
|
+
index := 0
|
|
477
|
+
if selector.Sel.Name == "HandleHTTP" {
|
|
478
|
+
index = 1
|
|
479
|
+
}
|
|
480
|
+
if len(call.Args) > index {
|
|
481
|
+
switch arg := call.Args[index].(type) {
|
|
171
482
|
case *ast.BasicLit:
|
|
172
483
|
if arg.Kind == token.STRING {
|
|
173
484
|
method, _ = strconv.Unquote(arg.Value)
|
|
@@ -181,14 +492,34 @@ func main() {
|
|
|
181
492
|
} else if _, declared := in.Permissions[method]; !declared && !sdk[method] {
|
|
182
493
|
problem(call.Pos(), "registered method missing from permissions.json: "+method)
|
|
183
494
|
}
|
|
495
|
+
if selector.Sel.Name == "HandleHTTP" {
|
|
496
|
+
prefix := ""
|
|
497
|
+
if len(call.Args) > 0 {
|
|
498
|
+
prefix = stringLiteral(call.Args[0])
|
|
499
|
+
}
|
|
500
|
+
if !regexp.MustCompile(`^/([A-Za-z0-9_-]+/)+$`).MatchString(prefix) {
|
|
501
|
+
problem(call.Pos(), "HTTP prefix must be a fixed literal full-segment path ending in /")
|
|
502
|
+
}
|
|
503
|
+
for _, reserved := range []string{"/_sso/", "/_spl/", "/api/", "/api.cgi/", "/rpc/", "/webman/", "/cgi/"} {
|
|
504
|
+
if strings.HasPrefix(prefix, reserved) || strings.HasPrefix(reserved, prefix) {
|
|
505
|
+
problem(call.Pos(), "HTTP prefix overlaps a reserved path")
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if sdk[method] || in.Permissions[method] != "authenticated" && in.Permissions[method] != "administrators" {
|
|
509
|
+
problem(call.Pos(), "HTTP routes require an explicit App authenticated or administrators permission")
|
|
510
|
+
}
|
|
511
|
+
for _, other := range out.HTTPRoutes {
|
|
512
|
+
if strings.HasPrefix(prefix, other.Prefix) || strings.HasPrefix(other.Prefix, prefix) {
|
|
513
|
+
problem(call.Pos(), "HTTP prefixes must not overlap")
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
out.HTTPRoutes = append(out.HTTPRoutes, httpRoute{prefix, method})
|
|
517
|
+
}
|
|
184
518
|
return true
|
|
185
519
|
})
|
|
186
|
-
return nil
|
|
187
|
-
})
|
|
188
|
-
if err != nil {
|
|
189
|
-
fmt.Fprintln(os.Stderr, err)
|
|
190
|
-
os.Exit(1)
|
|
191
520
|
}
|
|
521
|
+
sort.Strings(out.Problems)
|
|
522
|
+
sort.Slice(out.HTTPRoutes, func(i, j int) bool { return out.HTTPRoutes[i].Prefix < out.HTTPRoutes[j].Prefix })
|
|
192
523
|
if err := json.NewEncoder(os.Stdout).Encode(out); err != nil {
|
|
193
524
|
os.Exit(1)
|
|
194
525
|
}
|
|
@@ -33,6 +33,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
33
33
|
if (!Array.isArray(methods) || methods.some(item => typeof item.method !== 'string' || typeof item.permission !== 'string'))
|
|
34
34
|
throw new StandaloneBuildError('CTL_DESCRIBE_FAILED', 'Invalid registry snapshot.');
|
|
35
35
|
const registry = new Map(methods.map(item => [item.method, item.permission]));
|
|
36
|
+
const rawHTTP = new Set(methods.filter(item => item.kind === 'http').map(item => item.method));
|
|
36
37
|
// Identity transport routes are outside the business registry but have immutable SDK ownership.
|
|
37
38
|
if (m.capabilities.identity) {
|
|
38
39
|
registry.set('identity.session', 'public');
|
|
@@ -46,6 +47,8 @@ export async function buildApiDescription(m, bridgeMethods, run) {
|
|
|
46
47
|
const names = flowMethods(command.flow);
|
|
47
48
|
let permission = 'public';
|
|
48
49
|
for (const name of names) {
|
|
50
|
+
if (rawHTTP.has(name))
|
|
51
|
+
throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references raw HTTP route ${name}; ctl commands require RPC methods.`);
|
|
49
52
|
if (!registry.has(name))
|
|
50
53
|
throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references unregistered method ${name}.`);
|
|
51
54
|
const effective = m.entry === 'admin-only' ? 'administrators' : registry.get(name);
|
package/dist/standalone/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildInstallSettings } from './install-settings.js';
|
|
2
|
-
import { auditGoRoutes } from './route-audit.js';
|
|
2
|
+
import { auditGoRoutes, httpRoutesDigest } from './route-audit.js';
|
|
3
3
|
import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
|
|
4
4
|
import { buildApiDescription } from "./api-description.js";
|
|
5
5
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
@@ -234,7 +234,7 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
|
|
|
234
234
|
throw new StandaloneBuildError("STANDALONE_API_PERMISSIONS_CHANGED", "API permissions changed during build; check and rebuild.");
|
|
235
235
|
}
|
|
236
236
|
const policy = readApiPermissions(backend, manifest.capabilities);
|
|
237
|
-
const audit = auditGoRoutes(backend.dir, policy.policy, policy.sdk);
|
|
237
|
+
const audit = auditGoRoutes(backend.dir, policy.policy, policy.sdk, [backend.main], [goBuildEnv(arch).GOARCH]);
|
|
238
238
|
if (audit.problems.length || manifest.entry && !audit.entryGate) {
|
|
239
239
|
throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n") || "App entry gate was removed during build.");
|
|
240
240
|
}
|
|
@@ -252,6 +252,8 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
|
|
|
252
252
|
`-X ${module}/internal/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
|
|
253
253
|
`-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
|
|
254
254
|
`-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
|
|
255
|
+
`-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
|
|
256
|
+
`-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
|
|
255
257
|
...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
|
|
256
258
|
...delegateBuildFlags(manifest),
|
|
257
259
|
...(manifest.executor
|
|
@@ -302,11 +304,20 @@ async function buildBackgroundWorker(manifest, arch, work, run) {
|
|
|
302
304
|
if (!manifest.capabilities.jobs || manifest.backend.kind !== "go")
|
|
303
305
|
return undefined;
|
|
304
306
|
const out = join(work, `background-worker-${arch}`);
|
|
307
|
+
const policy = readApiPermissions(manifest.backend, manifest.capabilities);
|
|
308
|
+
const audit = auditGoRoutes(manifest.backend.dir, policy.policy, policy.sdk, [BACKGROUND_WORKER_MAIN], [goBuildEnv(arch).GOARCH]);
|
|
309
|
+
if (audit.problems.length)
|
|
310
|
+
throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n"));
|
|
305
311
|
const ldflags = [
|
|
306
312
|
"-s",
|
|
307
313
|
"-w",
|
|
308
314
|
"-buildid=",
|
|
309
315
|
`-X main.packageID=${manifest.package}`,
|
|
316
|
+
`-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
|
|
317
|
+
`-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
|
|
318
|
+
`-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
|
|
319
|
+
`-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
|
|
320
|
+
...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
|
|
310
321
|
...delegateBuildFlags(manifest),
|
|
311
322
|
...(manifest.executor
|
|
312
323
|
? [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readInstallSettings } from './install-settings.js';
|
|
2
2
|
import { auditGoRoutes } from './route-audit.js';
|
|
3
|
+
import { BACKGROUND_WORKER_MAIN } from './background.js';
|
|
3
4
|
import { isWebLaunchMode } from "../contract/web-launch.js";
|
|
4
5
|
import { validWebAPIProfiles, } from "../runtime/webapi-profile.js";
|
|
5
6
|
import { validateMetadata } from "../package-metadata.js";
|
|
@@ -354,11 +355,18 @@ function readManifest(raw, c) {
|
|
|
354
355
|
}
|
|
355
356
|
if (backend?.kind === "go" && permissions && sdkVersionOrUndefined(backend) && (raw.entry === undefined || entry)) {
|
|
356
357
|
try {
|
|
357
|
-
const
|
|
358
|
-
for (const
|
|
358
|
+
const problems = new Set();
|
|
359
|
+
for (const main of [backend.main, ...(capabilities.jobs ? [BACKGROUND_WORKER_MAIN] : [])]) {
|
|
360
|
+
for (const arch of arches ?? []) {
|
|
361
|
+
const audit = auditGoRoutes(backend.dir, permissions.policy, permissions.sdk, [main], [({ x86_64: 'amd64', aarch64: 'arm64', armv7: 'arm' })[arch]]);
|
|
362
|
+
for (const problem of audit.problems)
|
|
363
|
+
problems.add(problem);
|
|
364
|
+
if (main === backend.main && entry && !audit.entryGate)
|
|
365
|
+
c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const problem of problems)
|
|
359
369
|
c.fail("STANDALONE_API_REGISTRY_INVALID", problem);
|
|
360
|
-
if (entry && !audit.entryGate)
|
|
361
|
-
c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
|
|
362
370
|
}
|
|
363
371
|
catch (error) {
|
|
364
372
|
c.fail("STANDALONE_API_REGISTRY_INVALID", error.message);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ApiPermissions } from './api-permissions.js';
|
|
2
|
-
|
|
3
|
-
export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>): {
|
|
2
|
+
export interface RouteAudit {
|
|
4
3
|
problems: string[];
|
|
5
4
|
entryGate: boolean;
|
|
6
|
-
|
|
5
|
+
httpRoutes: {
|
|
6
|
+
prefix: string;
|
|
7
|
+
method: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export declare function httpRoutesDigest(audit: RouteAudit): string;
|
|
11
|
+
/** Parse Go syntax without executing an App's main, init functions or --describe. */
|
|
12
|
+
export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>, roots?: string[], arches?: string[]): RouteAudit;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { goChildEnv } from '../runtime/go-env.js';
|
|
7
8
|
let binary;
|
|
9
|
+
export function httpRoutesDigest(audit) {
|
|
10
|
+
return createHash('sha256').update(JSON.stringify(audit.httpRoutes)).digest('hex');
|
|
11
|
+
}
|
|
8
12
|
/** Parse Go syntax without executing an App's main, init functions or --describe. */
|
|
9
|
-
export function auditGoRoutes(dir, policy, sdk) {
|
|
13
|
+
export function auditGoRoutes(dir, policy, sdk, roots = ['.'], arches = ['amd64', 'arm64', 'arm']) {
|
|
10
14
|
const env = goChildEnv({ ...process.env, GOOS: undefined, GOARCH: undefined, GOARM: undefined, GOWORK: 'off', CGO_ENABLED: '0' });
|
|
11
15
|
if (!binary) {
|
|
12
16
|
const work = mkdtempSync(join(tmpdir(), 'synopkgland-route-audit-'));
|
|
@@ -18,7 +22,7 @@ export function auditGoRoutes(dir, policy, sdk) {
|
|
|
18
22
|
throw new Error('Cannot build the Go route auditor; install Go and retry.');
|
|
19
23
|
binary = out;
|
|
20
24
|
}
|
|
21
|
-
const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout:
|
|
25
|
+
const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Roots: roots, Arches: arches, Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20 });
|
|
22
26
|
if (result.error || result.status !== 0)
|
|
23
27
|
throw new Error(`Cannot audit Go routes: ${result.stderr?.trim() || 'auditor unavailable'}`);
|
|
24
28
|
return JSON.parse(result.stdout);
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
CLI 0.
|
|
8
|
+
CLI 0.35.0 installs templates through `~0.32.0`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -24,12 +24,22 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
|
|
|
24
24
|
|
|
25
25
|
For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
|
|
26
26
|
|
|
27
|
-
Use SDK 0.
|
|
28
|
-
in the permissions table; dynamic names and
|
|
27
|
+
Use SDK 0.33.0 and CLI 0.35.0 for App entry enforcement. Declare every App method
|
|
28
|
+
in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
|
|
29
29
|
Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
|
|
30
30
|
paths that rely on upstream authentication. Read
|
|
31
31
|
[reference/identity.md](reference/identity.md) and
|
|
32
32
|
[reference/identity-gateway.md](reference/identity-gateway.md) before migration.
|
|
33
|
+
For private worker HTTP, use `ipc.ServeUnix(ctx, path, handler)` and
|
|
34
|
+
`ipc.DialUnix`, never a custom listener. The helper requires an owned mode-0600
|
|
35
|
+
socket under the package's private `var/run`. For browser proxies, streams or
|
|
36
|
+
initial upgrades, register `api.HandleHTTP("/proxy/", "app.proxy", handler)`
|
|
37
|
+
in the same API registry and declare the method as authenticated or
|
|
38
|
+
administrators. Prefixes are literal, disjoint full segments outside reserved
|
|
39
|
+
SDK paths. Origin, entry, identity and permission checks still apply. CGI
|
|
40
|
+
cannot upgrade a connection; use an existing upgrade-capable deployment
|
|
41
|
+
transport. The audit follows each configured Go binary's Linux import graph,
|
|
42
|
+
including declared workers; unshipped dev commands are excluded.
|
|
33
43
|
|
|
34
44
|
**If you package someone else's open-source project, set `maintainer` to the
|
|
35
45
|
original project/owner (prefer the project's own team/org name, else the owner's
|