Redact and cap subprocess diagnostics
This commit is contained in:
360
internal/adapters/subprocess/diagnostics.go
Normal file
360
internal/adapters/subprocess/diagnostics.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package subprocess
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxStdoutDiagnosticBytes bounds persisted stdout from one external command.
|
||||
MaxStdoutDiagnosticBytes int64 = 8 * 1024 * 1024
|
||||
// MaxStderrDiagnosticBytes bounds persisted stderr from one external command.
|
||||
MaxStderrDiagnosticBytes int64 = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
var inheritedEnvironmentNames = map[string]struct{}{
|
||||
"COMSPEC": {},
|
||||
"HOME": {},
|
||||
"PATH": {},
|
||||
"SYSTEMROOT": {},
|
||||
"TMP": {},
|
||||
"TMPDIR": {},
|
||||
"TEMP": {},
|
||||
"WINDIR": {},
|
||||
// These test-only helper destinations let the adapter package tests exercise
|
||||
// real command invocation without widening the production environment.
|
||||
"AUDITA_HELPER_RECORD_PATH": {},
|
||||
"AUDITA_HELPER_MODE": {},
|
||||
"GO_WANT_AUDITA_HELPER": {},
|
||||
"GO_WANT_SCRIPTORIUM_HELPER": {},
|
||||
"GO_WANT_SERIATIM_HELPER": {},
|
||||
"GO_WANT_SUBPROCESS_HELPER": {},
|
||||
"NOTARIUS_CAPTURE_DIR": {},
|
||||
"NOTARIUS_RECEIPT_FIXTURE": {},
|
||||
"SCRIPTORIUM_HELPER_RECORD_PATH": {},
|
||||
"SCRIPTORIUM_HELPER_MODE": {},
|
||||
"SERIATIM_HELPER_RECORD_PATH": {},
|
||||
"SERIATIM_HELPER_MODE": {},
|
||||
}
|
||||
|
||||
var sensitiveEnvironmentNames = map[string]struct{}{
|
||||
"ANTHROPIC_API_KEY": {},
|
||||
"API_KEY": {},
|
||||
"AUDITA_LLM_API_KEY": {},
|
||||
"AWS_ACCESS_KEY_ID": {},
|
||||
"AWS_SECRET_ACCESS_KEY": {},
|
||||
"AWS_SESSION_TOKEN": {},
|
||||
"OPENAI_API_KEY": {},
|
||||
"OPENROUTER_API_KEY": {},
|
||||
}
|
||||
|
||||
type captureLimitError struct {
|
||||
stream string
|
||||
owner string
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (e *captureLimitError) Error() string {
|
||||
return fmt.Sprintf("%s diagnostic capture for %s exceeded %d bytes", e.stream, e.owner, e.limit)
|
||||
}
|
||||
|
||||
type logWriters struct {
|
||||
files []*os.File
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
|
||||
limits chan *captureLimitError
|
||||
mu sync.Mutex
|
||||
limit *captureLimitError
|
||||
stdout *diagnosticWriter
|
||||
stderr *diagnosticWriter
|
||||
}
|
||||
|
||||
type diagnosticWriter struct {
|
||||
logs *logWriters
|
||||
stream string
|
||||
owner string
|
||||
target io.Writer
|
||||
limit int64
|
||||
received int64
|
||||
persisted int64
|
||||
redactor streamRedactor
|
||||
}
|
||||
|
||||
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
|
||||
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
||||
cleanStdout := cleanLogPath(stdoutPath)
|
||||
cleanStderr := cleanLogPath(stderrPath)
|
||||
|
||||
stdoutFile, err := openDiagnosticFile(cleanStdout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open stdout log: %w", err)
|
||||
}
|
||||
stderrFile := stdoutFile
|
||||
if cleanStdout != cleanStderr {
|
||||
stderrFile, err = openDiagnosticFile(cleanStderr)
|
||||
if err != nil {
|
||||
_ = stdoutFile.Close()
|
||||
return nil, fmt.Errorf("open stderr log: %w", err)
|
||||
}
|
||||
}
|
||||
if cleanStdout == cleanStderr {
|
||||
logs.files = []*os.File{stdoutFile}
|
||||
} else {
|
||||
logs.files = []*os.File{stdoutFile, stderrFile}
|
||||
}
|
||||
|
||||
logs.stdout = newDiagnosticWriter(logs, "stdout", owner, stdoutFile, MaxStdoutDiagnosticBytes, sensitiveValues)
|
||||
logs.stderr = newDiagnosticWriter(logs, "stderr", owner, stderrFile, MaxStderrDiagnosticBytes, sensitiveValues)
|
||||
logs.Stdout = logs.stdout
|
||||
logs.Stderr = logs.stderr
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func newDiagnosticWriter(logs *logWriters, stream, owner string, target io.Writer, limit int64, sensitiveValues []string) *diagnosticWriter {
|
||||
return &diagnosticWriter{
|
||||
logs: logs,
|
||||
stream: stream,
|
||||
owner: owner,
|
||||
target: target,
|
||||
limit: limit,
|
||||
redactor: newStreamRedactor(sensitiveValues),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *diagnosticWriter) Write(data []byte) (int, error) {
|
||||
if w.received >= w.limit {
|
||||
return len(data), w.reachLimit()
|
||||
}
|
||||
accepted := data
|
||||
if remaining := w.limit - w.received; int64(len(accepted)) > remaining {
|
||||
accepted = accepted[:remaining]
|
||||
}
|
||||
w.received += int64(len(accepted))
|
||||
if err := w.writeRedacted(w.redactor.Write(accepted)); err != nil {
|
||||
return len(data), err
|
||||
}
|
||||
if len(accepted) != len(data) {
|
||||
return len(data), w.reachLimit()
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (w *diagnosticWriter) Flush() error {
|
||||
return w.writeRedacted(w.redactor.Flush())
|
||||
}
|
||||
|
||||
func (w *diagnosticWriter) writeRedacted(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
w.logs.mu.Lock()
|
||||
remaining := w.limit - w.persisted
|
||||
if remaining <= 0 {
|
||||
w.logs.mu.Unlock()
|
||||
return w.reachLimit()
|
||||
}
|
||||
toWrite := data
|
||||
exceeded := int64(len(data)) > remaining
|
||||
if exceeded {
|
||||
toWrite = toWrite[:remaining]
|
||||
}
|
||||
written, err := w.target.Write(toWrite)
|
||||
w.persisted += int64(written)
|
||||
w.logs.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exceeded {
|
||||
return w.reachLimit()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *diagnosticWriter) reachLimit() error {
|
||||
limit := &captureLimitError{stream: w.stream, owner: w.owner, limit: w.limit}
|
||||
w.logs.mu.Lock()
|
||||
if w.logs.limit == nil {
|
||||
w.logs.limit = limit
|
||||
w.logs.limits <- limit
|
||||
}
|
||||
w.logs.mu.Unlock()
|
||||
return limit
|
||||
}
|
||||
|
||||
func (l *logWriters) Limits() <-chan *captureLimitError { return l.limits }
|
||||
|
||||
func (l *logWriters) Limit() *captureLimitError {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.limit
|
||||
}
|
||||
|
||||
func (l *logWriters) Flush() error {
|
||||
return joinErrors(l.stdout.Flush(), l.stderr.Flush())
|
||||
}
|
||||
|
||||
func (l *logWriters) Close() {
|
||||
_ = l.Flush()
|
||||
for _, file := range l.files {
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func cleanLogPath(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(trimmed)
|
||||
}
|
||||
|
||||
func openDiagnosticFile(path string) (*os.File, error) {
|
||||
if path == "" {
|
||||
return os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
}
|
||||
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
||||
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
||||
}
|
||||
if err := file.Chmod(fileops.WorkspaceFileMode); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("set log file permissions %q: %w", path, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (r RunRequest) diagnosticOwner() string {
|
||||
if owner := strings.TrimSpace(r.DiagnosticOwner); owner != "" {
|
||||
return owner
|
||||
}
|
||||
return "subprocess"
|
||||
}
|
||||
|
||||
func buildChildEnvironment(base []string, overrides map[string]string) []string {
|
||||
values := make(map[string]string, len(inheritedEnvironmentNames)+len(overrides))
|
||||
for _, item := range base {
|
||||
name, value, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized := strings.ToUpper(name)
|
||||
if _, allowed := inheritedEnvironmentNames[normalized]; allowed {
|
||||
values[name] = value
|
||||
}
|
||||
}
|
||||
for name, value := range overrides {
|
||||
values[name] = value
|
||||
}
|
||||
names := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, name+"="+values[name])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sensitiveEnvironmentValues(environment []string, additionalNames []string) []string {
|
||||
names := make(map[string]struct{}, len(sensitiveEnvironmentNames)+len(additionalNames))
|
||||
for name := range sensitiveEnvironmentNames {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
for _, name := range additionalNames {
|
||||
if trimmed := strings.ToUpper(strings.TrimSpace(name)); trimmed != "" {
|
||||
names[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
values := make([]string, 0, len(names))
|
||||
for _, item := range environment {
|
||||
name, value, ok := strings.Cut(item, "=")
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, sensitive := names[strings.ToUpper(name)]; sensitive {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
type streamRedactor struct {
|
||||
values []string
|
||||
buffer []byte
|
||||
maxLen int
|
||||
}
|
||||
|
||||
func newStreamRedactor(values []string) streamRedactor {
|
||||
unique := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
unique[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
sorted := make([]string, 0, len(unique))
|
||||
for value := range unique {
|
||||
sorted = append(sorted, value)
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i]) > len(sorted[j]) })
|
||||
maxLen := 1
|
||||
for _, value := range sorted {
|
||||
if len(value) > maxLen {
|
||||
maxLen = len(value)
|
||||
}
|
||||
}
|
||||
return streamRedactor{values: sorted, maxLen: maxLen}
|
||||
}
|
||||
|
||||
func (r *streamRedactor) Write(data []byte) []byte {
|
||||
r.buffer = append(r.buffer, data...)
|
||||
safeCut := len(r.buffer) - r.maxLen + 1
|
||||
if safeCut <= 0 {
|
||||
return nil
|
||||
}
|
||||
emitCut := safeCut
|
||||
for _, value := range r.values {
|
||||
start := 0
|
||||
for {
|
||||
index := bytes.Index(r.buffer[start:], []byte(value))
|
||||
if index < 0 {
|
||||
break
|
||||
}
|
||||
index += start
|
||||
if index+len(value) > safeCut && index < emitCut {
|
||||
emitCut = index
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
output := redactBytes(r.buffer[:emitCut], r.values)
|
||||
r.buffer = append(r.buffer[:0], r.buffer[emitCut:]...)
|
||||
return output
|
||||
}
|
||||
|
||||
func (r *streamRedactor) Flush() []byte {
|
||||
output := redactBytes(r.buffer, r.values)
|
||||
r.buffer = nil
|
||||
return output
|
||||
}
|
||||
|
||||
func redactBytes(data []byte, values []string) []byte {
|
||||
out := append([]byte(nil), data...)
|
||||
for _, value := range values {
|
||||
out = bytes.ReplaceAll(out, []byte(value), []byte("<redacted>"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user