初始化仓库
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
//go:build !windows
|
||||
|
||||
package core
|
||||
|
||||
// runas_audit.go — isolation leak-audit probe for the run_as_user sandbox.
|
||||
//
|
||||
// The preflight gates in runas_check.go answer the question "can
|
||||
// cc-connect spawn as the target user without errors?". This file
|
||||
// answers the stronger question: "once the target user IS spawned, can
|
||||
// it still read things it shouldn't be able to?".
|
||||
//
|
||||
// We do that by running a fixed shell script inside the target user's
|
||||
// sudo -i session and parsing its output into a structured report. The
|
||||
// script (runas_probe.sh) is embedded via //go:embed so it ships with the
|
||||
// binary and can be audited with shellcheck.
|
||||
//
|
||||
// # Failure policy
|
||||
//
|
||||
// Per the spec: unexpected audit outcomes are FATAL. Specifically:
|
||||
//
|
||||
// - Any CROSS_LEAKED (the target user can read another project user's
|
||||
// secrets) is fatal.
|
||||
// - Any SUPERVISOR_LEAKED (the target user can read the supervisor's
|
||||
// secrets) is fatal.
|
||||
// - WORKDIR_WRITABLE=no is fatal (already caught by preflight, but
|
||||
// we assert it here too as defense in depth).
|
||||
//
|
||||
// Everything else is informational and stored in the report but does
|
||||
// not block startup.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed runas_probe.sh
|
||||
var runasProbeScript []byte
|
||||
|
||||
// Probe output tags. These must stay in sync with runas_probe.sh — the
|
||||
// shell script and the Go parser share this as their wire format.
|
||||
const (
|
||||
tagBegin = "BEGIN"
|
||||
tagEnd = "END"
|
||||
tagID = "ID"
|
||||
tagWhoami = "WHOAMI"
|
||||
tagGroups = "GROUPS"
|
||||
tagUmask = "UMASK"
|
||||
tagPwd = "PWD"
|
||||
tagHome = "HOME"
|
||||
tagShell = "SHELL"
|
||||
tagWorkDirPath = "WORKDIR_PATH"
|
||||
tagWorkDirExists = "WORKDIR_EXISTS"
|
||||
tagWorkDirReadable = "WORKDIR_READABLE"
|
||||
tagWorkDirWritable = "WORKDIR_WRITABLE"
|
||||
tagTargetHas = "TARGET_HAS"
|
||||
tagTargetMissing = "TARGET_MISSING"
|
||||
tagCrossDenied = "CROSS_DENIED"
|
||||
tagCrossLeaked = "CROSS_LEAKED"
|
||||
tagCrossMissing = "CROSS_MISSING"
|
||||
tagCrossUnknown = "CROSS_UNKNOWN"
|
||||
tagSupervisorDenied = "SUPERVISOR_DENIED"
|
||||
tagSupervisorLeaked = "SUPERVISOR_LEAKED"
|
||||
tagSupervisorMissing = "SUPERVISOR_MISSING"
|
||||
)
|
||||
|
||||
// ProbeScript returns the embedded probe script. Doctor subcommand
|
||||
// exposes this via --print-script for inspection.
|
||||
func ProbeScript() []byte { return runasProbeScript }
|
||||
|
||||
// IsolationReport is the structured result of running the probe.
|
||||
type IsolationReport struct {
|
||||
Project string `json:"project"`
|
||||
RunAsUser string `json:"run_as_user"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Identity IdentitySnapshot `json:"identity"`
|
||||
WorkDirStatus WorkDirStatus `json:"work_dir_status"`
|
||||
// TargetPaths lists existence results for files the target user is
|
||||
// supposed to have in their own home. Missing is informational —
|
||||
// runtime tools will fail, but it's an operator migration gap, not
|
||||
// a security hole.
|
||||
TargetPaths []PathStatus `json:"target_paths"`
|
||||
CrossUser []CrossUserResult `json:"cross_user"`
|
||||
Supervisor []PathStatus `json:"supervisor"`
|
||||
// Fatal lists audit-level fatal problems: any CROSS_LEAKED,
|
||||
// SUPERVISOR_LEAKED, or WORKDIR_WRITABLE=no.
|
||||
Fatal []string `json:"fatal,omitempty"`
|
||||
// ProbeVersion is the version string from the probe's BEGIN line;
|
||||
// bumped when the report schema changes.
|
||||
ProbeVersion string `json:"probe_version"`
|
||||
// RawOutput is only populated when the audit had a fatal problem,
|
||||
// to keep clean reports small.
|
||||
RawOutput string `json:"raw_output,omitempty"`
|
||||
}
|
||||
|
||||
func (r IsolationReport) HasFatal() bool { return len(r.Fatal) > 0 }
|
||||
|
||||
type IdentitySnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Whoami string `json:"whoami"`
|
||||
Groups string `json:"groups"`
|
||||
Umask string `json:"umask"`
|
||||
Pwd string `json:"pwd"`
|
||||
Home string `json:"home"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
|
||||
type WorkDirStatus struct {
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
Readable bool `json:"readable"`
|
||||
Writable bool `json:"writable"`
|
||||
}
|
||||
|
||||
type PathStatus struct {
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // has | missing | denied | leaked
|
||||
}
|
||||
|
||||
type CrossUserResult struct {
|
||||
OtherUser string `json:"other_user"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // missing | denied | leaked | unknown-user
|
||||
}
|
||||
|
||||
// PrettyJSON marshals the report with two-space indentation for use in
|
||||
// the doctor subcommand's on-disk report.
|
||||
func (r IsolationReport) PrettyJSON() ([]byte, error) {
|
||||
return json.MarshalIndent(r, "", " ")
|
||||
}
|
||||
|
||||
type AuditConfig struct {
|
||||
Project string
|
||||
RunAsUser string
|
||||
WorkDir string
|
||||
// OtherUsers: other run_as_user values configured in the same
|
||||
// instance, used for the cross-user denial leg of the probe.
|
||||
OtherUsers []string
|
||||
// Supervisor: the supervisor Unix username, used for the
|
||||
// supervisor-denial leg. Usually os/user.Current().Username.
|
||||
Supervisor string
|
||||
Runner SudoRunner
|
||||
// ProbeScriptOverride, if non-nil, replaces the embedded probe
|
||||
// script. Tests use this; production always uses the embedded one.
|
||||
ProbeScriptOverride []byte
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// RunIsolationProbe spawns the probe as the target user and parses its
|
||||
// output. Does not fail on non-zero exit from the probe — whatever it
|
||||
// managed to print is still parsed.
|
||||
func RunIsolationProbe(ctx context.Context, cfg AuditConfig) (IsolationReport, error) {
|
||||
report := IsolationReport{
|
||||
Project: cfg.Project,
|
||||
RunAsUser: cfg.RunAsUser,
|
||||
WorkDir: cfg.WorkDir,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
if cfg.RunAsUser == "" {
|
||||
return report, errors.New("RunIsolationProbe: RunAsUser is empty")
|
||||
}
|
||||
if cfg.Runner == nil {
|
||||
cfg.Runner = ExecSudoRunner{}
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 15 * time.Second
|
||||
}
|
||||
script := cfg.ProbeScriptOverride
|
||||
if script == nil {
|
||||
script = runasProbeScript
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Build env injection: since sudo -i strips env, we pass the probe
|
||||
// inputs as SHELL VARIABLES by prepending `export` statements to the
|
||||
// script body. Values are pre-validated at config parse time so
|
||||
// shell-quoting concerns are limited, but we still quote everything.
|
||||
header := fmt.Sprintf(
|
||||
"export CC_PROBE_WORKDIR=%s\nexport CC_PROBE_OTHER_USERS=%s\nexport CC_PROBE_SUPERVISOR=%s\n",
|
||||
shellQuote(cfg.WorkDir),
|
||||
shellQuote(strings.Join(filterOtherUsers(cfg.OtherUsers, cfg.RunAsUser), " ")),
|
||||
shellQuote(cfg.Supervisor),
|
||||
)
|
||||
fullScript := append([]byte(header), script...)
|
||||
|
||||
// We invoke `sudo -n -iu <user> -- /bin/sh -s` and pipe the script on
|
||||
// stdin. Using -s + stdin avoids argv-length limits and avoids ever
|
||||
// putting the script body on the command line.
|
||||
cmd := exec.CommandContext(probeCtx, "sudo",
|
||||
"-n", "-iu", cfg.RunAsUser, "--", "/bin/sh", "-s")
|
||||
cmd.Stdin = bytes.NewReader(fullScript)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Still try to parse anything that made it out. Return the err
|
||||
// so callers can tell the probe didn't complete cleanly.
|
||||
report.RawOutput = stdout.String()
|
||||
parseProbeOutput(&report, stdout.String())
|
||||
return report, fmt.Errorf("probe exec failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
parseProbeOutput(&report, stdout.String())
|
||||
report.Fatal = computeAuditFatal(report)
|
||||
// RawOutput bloats the on-disk report — only keep it when something
|
||||
// went wrong so an operator can inspect what the probe actually saw.
|
||||
if report.HasFatal() {
|
||||
report.RawOutput = stdout.String()
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// parseProbeOutput fills report in place. Unknown tags are ignored for
|
||||
// forward compatibility with newer probe scripts.
|
||||
func parseProbeOutput(report *IsolationReport, out string) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
tag, rest := splitTag(line)
|
||||
switch tag {
|
||||
case tagBegin:
|
||||
if strings.HasPrefix(rest, "probe-version=") {
|
||||
report.ProbeVersion = strings.TrimPrefix(rest, "probe-version=")
|
||||
}
|
||||
case tagEnd:
|
||||
case tagID:
|
||||
report.Identity.ID = rest
|
||||
case tagWhoami:
|
||||
report.Identity.Whoami = rest
|
||||
case tagGroups:
|
||||
report.Identity.Groups = rest
|
||||
case tagUmask:
|
||||
report.Identity.Umask = rest
|
||||
case tagPwd:
|
||||
report.Identity.Pwd = rest
|
||||
case tagHome:
|
||||
report.Identity.Home = rest
|
||||
case tagShell:
|
||||
report.Identity.Shell = rest
|
||||
case tagWorkDirPath:
|
||||
report.WorkDirStatus.Path = rest
|
||||
case tagWorkDirExists:
|
||||
report.WorkDirStatus.Exists = rest == "yes"
|
||||
case tagWorkDirReadable:
|
||||
report.WorkDirStatus.Readable = rest == "yes"
|
||||
case tagWorkDirWritable:
|
||||
report.WorkDirStatus.Writable = rest == "yes"
|
||||
case tagTargetHas:
|
||||
report.TargetPaths = append(report.TargetPaths, PathStatus{Path: rest, Status: "has"})
|
||||
case tagTargetMissing:
|
||||
report.TargetPaths = append(report.TargetPaths, PathStatus{Path: rest, Status: "missing"})
|
||||
case tagCrossDenied, tagCrossLeaked, tagCrossMissing, tagCrossUnknown:
|
||||
other, path := splitTag(rest)
|
||||
status := strings.ToLower(strings.TrimPrefix(tag, "CROSS_"))
|
||||
if tag == tagCrossUnknown {
|
||||
status = "unknown-user"
|
||||
other = rest
|
||||
path = ""
|
||||
}
|
||||
report.CrossUser = append(report.CrossUser, CrossUserResult{
|
||||
OtherUser: other,
|
||||
Path: path,
|
||||
Status: status,
|
||||
})
|
||||
case tagSupervisorDenied:
|
||||
report.Supervisor = append(report.Supervisor, PathStatus{Path: rest, Status: "denied"})
|
||||
case tagSupervisorLeaked:
|
||||
report.Supervisor = append(report.Supervisor, PathStatus{Path: rest, Status: "leaked"})
|
||||
case tagSupervisorMissing:
|
||||
report.Supervisor = append(report.Supervisor, PathStatus{Path: rest, Status: "missing"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeAuditFatal(r IsolationReport) []string {
|
||||
var fatal []string
|
||||
for _, c := range r.CrossUser {
|
||||
if c.Status == "leaked" {
|
||||
fatal = append(fatal, fmt.Sprintf(
|
||||
"project %q: target user %q can read %q belonging to user %q (CROSS_LEAKED)",
|
||||
r.Project, r.RunAsUser, c.Path, c.OtherUser))
|
||||
}
|
||||
}
|
||||
for _, s := range r.Supervisor {
|
||||
if s.Status == "leaked" {
|
||||
fatal = append(fatal, fmt.Sprintf(
|
||||
"project %q: target user %q can read supervisor path %q (SUPERVISOR_LEAKED)",
|
||||
r.Project, r.RunAsUser, s.Path))
|
||||
}
|
||||
}
|
||||
if r.WorkDirStatus.Path != "" && !r.WorkDirStatus.Writable {
|
||||
fatal = append(fatal, fmt.Sprintf(
|
||||
"project %q: target user %q cannot write work_dir %q (WORKDIR_WRITABLE=no)",
|
||||
r.Project, r.RunAsUser, r.WorkDirStatus.Path))
|
||||
}
|
||||
return fatal
|
||||
}
|
||||
|
||||
func splitTag(line string) (string, string) {
|
||||
sp := strings.IndexByte(line, ' ')
|
||||
if sp < 0 {
|
||||
return line, ""
|
||||
}
|
||||
return line[:sp], line[sp+1:]
|
||||
}
|
||||
|
||||
// shellQuote wraps s in POSIX single quotes, escaping embedded quotes.
|
||||
// Used instead of fmt %q because the probe runs under /bin/sh.
|
||||
func shellQuote(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func filterOtherUsers(others []string, self string) []string {
|
||||
out := make([]string, 0, len(others))
|
||||
for _, o := range others {
|
||||
if o == "" || o == self {
|
||||
continue
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user