mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add Sandbox Inspection and Debugging Commands (#261)
* feat: add sandbox DX commands * fix: Linter errors * fix: Sandbox deny log parsing * fix: Sandbox docs * refactor: Maintain SSOT across pkg dependencies * fix: Linter errors
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/pmg/sandbox/util"
|
||||
)
|
||||
|
||||
// LintLevel categorises a lint issue. Errors indicate a profile that cannot
|
||||
// safely be used; warnings flag risky or contradictory configuration; info
|
||||
// surfaces minor cleanups (e.g. dead rules) that callers can hide by default.
|
||||
type LintLevel string
|
||||
|
||||
const (
|
||||
LintLevelError LintLevel = "error"
|
||||
LintLevelWarn LintLevel = "warn"
|
||||
LintLevelInfo LintLevel = "info"
|
||||
)
|
||||
|
||||
type LintIssue struct {
|
||||
Level LintLevel `json:"level"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Rule string `json:"rule,omitempty"`
|
||||
}
|
||||
|
||||
var variableTokenRe = regexp.MustCompile(`\$\{[^}]+\}`)
|
||||
|
||||
// LintProfile returns issues in stable order: schema errors, then warnings in
|
||||
// field-declaration order, then info-level findings.
|
||||
func LintProfile(policy *SandboxPolicy) []LintIssue {
|
||||
if policy == nil {
|
||||
return []LintIssue{{
|
||||
Level: LintLevelError,
|
||||
Code: "schema.invalid",
|
||||
Message: "policy is nil",
|
||||
}}
|
||||
}
|
||||
|
||||
var errors []LintIssue
|
||||
var warns []LintIssue
|
||||
var infos []LintIssue
|
||||
|
||||
schemaErr := policy.Validate()
|
||||
if schemaErr != nil {
|
||||
errors = append(errors, LintIssue{
|
||||
Level: LintLevelError,
|
||||
Code: "schema.invalid",
|
||||
Message: schemaErr.Error(),
|
||||
})
|
||||
}
|
||||
// ValidateResolved calls Validate first; only surface its error when the
|
||||
// basic schema was valid, so we don't duplicate the message above.
|
||||
if schemaErr == nil {
|
||||
if err := policy.ValidateResolved(); err != nil {
|
||||
errors = append(errors, LintIssue{
|
||||
Level: LintLevelError,
|
||||
Code: "schema.invalid",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// allowOnly marks lists where broadness and dead-rule checks make sense:
|
||||
// deny-lists and network allow_bind are out of scope for those checks.
|
||||
allowLists := []struct {
|
||||
name string
|
||||
rules []string
|
||||
allowOnly bool
|
||||
}{
|
||||
{"filesystem.allow_read", policy.Filesystem.AllowRead, true},
|
||||
{"filesystem.allow_write", policy.Filesystem.AllowWrite, true},
|
||||
{"filesystem.deny_read", policy.Filesystem.DenyRead, false},
|
||||
{"filesystem.deny_write", policy.Filesystem.DenyWrite, false},
|
||||
{"network.allow_outbound", policy.Network.AllowOutbound, false},
|
||||
{"network.deny_outbound", policy.Network.DenyOutbound, false},
|
||||
{"network.allow_bind", policy.Network.AllowBind, false},
|
||||
{"process.allow_exec", policy.Process.AllowExec, true},
|
||||
{"process.deny_exec", policy.Process.DenyExec, false},
|
||||
}
|
||||
|
||||
knownVars := strings.Join(util.SupportedVariables, ", ")
|
||||
for _, list := range allowLists {
|
||||
for i, rule := range list.rules {
|
||||
for _, tok := range variableTokenRe.FindAllString(rule, -1) {
|
||||
if util.IsSupportedVariable(tok) {
|
||||
continue
|
||||
}
|
||||
warns = append(warns, LintIssue{
|
||||
Level: LintLevelWarn,
|
||||
Code: "vars.unresolved",
|
||||
Message: "unsupported variable " + tok + " (known: " + knownVars + ")",
|
||||
Field: fieldRef(list.name, i),
|
||||
Rule: rule,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, list := range allowLists {
|
||||
if !list.allowOnly {
|
||||
continue
|
||||
}
|
||||
for i, rule := range list.rules {
|
||||
code, msg := broadCheck(rule)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
warns = append(warns, LintIssue{
|
||||
Level: LintLevelWarn,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Field: fieldRef(list.name, i),
|
||||
Rule: rule,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
conflictPairs := []struct {
|
||||
allowName string
|
||||
allow []string
|
||||
denyName string
|
||||
deny []string
|
||||
}{
|
||||
{"filesystem.allow_read", policy.Filesystem.AllowRead, "filesystem.deny_read", policy.Filesystem.DenyRead},
|
||||
{"filesystem.allow_write", policy.Filesystem.AllowWrite, "filesystem.deny_write", policy.Filesystem.DenyWrite},
|
||||
{"network.allow_outbound", policy.Network.AllowOutbound, "network.deny_outbound", policy.Network.DenyOutbound},
|
||||
{"process.allow_exec", policy.Process.AllowExec, "process.deny_exec", policy.Process.DenyExec},
|
||||
}
|
||||
for _, pair := range conflictPairs {
|
||||
denyIdx := map[string]int{}
|
||||
for i, r := range pair.deny {
|
||||
if _, exists := denyIdx[r]; !exists {
|
||||
denyIdx[r] = i
|
||||
}
|
||||
}
|
||||
for i, r := range pair.allow {
|
||||
if j, ok := denyIdx[r]; ok {
|
||||
warns = append(warns, LintIssue{
|
||||
Level: LintLevelWarn,
|
||||
Code: "conflict.allow_deny",
|
||||
Message: "rule appears in both " + fieldRef(pair.allowName, i) + " and " + fieldRef(pair.denyName, j) + "; deny takes precedence",
|
||||
Field: fieldRef(pair.allowName, i),
|
||||
Rule: r,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dead rules: a later rule is "shadowed" only if it is a strict prefix
|
||||
// match of an earlier rule whose pattern ends with "/**".
|
||||
for _, list := range allowLists {
|
||||
if !list.allowOnly {
|
||||
continue
|
||||
}
|
||||
for i, rule := range list.rules {
|
||||
for j := 0; j < i; j++ {
|
||||
earlier := list.rules[j]
|
||||
if !strings.HasSuffix(earlier, "/**") {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSuffix(earlier, "/**")
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if rule == earlier {
|
||||
continue
|
||||
}
|
||||
stripped := strings.TrimSuffix(rule, "/**")
|
||||
if stripped == prefix {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(stripped, prefix+"/") {
|
||||
infos = append(infos, LintIssue{
|
||||
Level: LintLevelInfo,
|
||||
Code: "dead.shadowed",
|
||||
Message: "rule shadowed by " + fieldRef(list.name, j) + " (" + earlier + ")",
|
||||
Field: fieldRef(list.name, i),
|
||||
Rule: rule,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]LintIssue, 0, len(errors)+len(warns)+len(infos))
|
||||
out = append(out, errors...)
|
||||
out = append(out, warns...)
|
||||
out = append(out, infos...)
|
||||
return out
|
||||
}
|
||||
|
||||
func broadCheck(rule string) (string, string) {
|
||||
switch rule {
|
||||
case "/**":
|
||||
return "broad.root_glob", "rule grants access to entire filesystem (/**)"
|
||||
case util.VarHome + "/**":
|
||||
return "broad.home_glob", "rule grants access to entire user home (" + util.VarHome + "/**)"
|
||||
case "**":
|
||||
return "broad.all_glob", "rule uses unrestricted glob (**)"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func fieldRef(name string, idx int) string {
|
||||
return name + "[" + strconv.Itoa(idx) + "]"
|
||||
}
|
||||
Reference in New Issue
Block a user