mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* 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
212 lines
5.9 KiB
Go
212 lines
5.9 KiB
Go
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) + "]"
|
|
}
|