diff --git a/README.md b/README.md index 47b2dc7..6a39157 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,28 @@ go run ./cmd/roomctl token issue \ --output .room/agent.token ``` -Configure a trusted analyzer executable, then start Room: +Install [Semgrep Community Edition](https://semgrep.dev/docs/getting-started/), +build the Linux adapter, and configure the repository it may scan: ```bash -ROOM_ANALYZER_EXECUTABLE=/absolute/path/to/analyzer \ -ROOM_ANALYZER_ID=company.security-analyzer \ +go build -o ~/.local/bin/room-semgrep ./cmd/room-semgrep + +ROOM_ANALYZER_EXECUTABLE="$HOME/.local/bin/room-semgrep" \ +ROOM_ANALYZER_ARGS='["--semgrep-core","/absolute/path/to/semgrep-core","--config","/absolute/path/to/room/analyzers/semgrep/room.yml","--repository-root","/absolute/path/to/repository","--covered-signal","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' \ +ROOM_ANALYZER_CONFIG_FILE=/absolute/path/to/room/analyzers/semgrep/room.yml \ +ROOM_ANALYZER_ID=room.semgrep \ ROOM_ANALYZER_VERSION=1 \ -ROOM_ANALYZER_COVERED_SIGNALS='["SIGNAL_KIND_RUST_UNSAFE_WITHOUT_SAFETY_CONTRACT"]' \ +ROOM_ANALYZER_COVERED_SIGNALS='["SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' \ go run ./cmd/roomd ``` +The included rule detects Go HTTP input that reaches SQL query text. The +adapter invokes the OSS `semgrep-core` binary directly so core parser and rule +skips remain visible. It snapshots regular changed files beneath +`--repository-root` and emits findings only when their source range intersects +an added diff line. Plan evaluation remains indeterminate because Semgrep +requires source files. + Without an analyzer, evaluations return `INDETERMINATE`; enforcement callers block that result. For local development only, authentication may be disabled on a loopback listener with `ROOM_AUTH_MODE=disabled`. diff --git a/analyzers/semgrep/room.yml b/analyzers/semgrep/room.yml new file mode 100644 index 0000000..f46091d --- /dev/null +++ b/analyzers/semgrep/room.yml @@ -0,0 +1,22 @@ +rules: + - id: room.go.dynamic-sql-with-untrusted-input + message: Untrusted HTTP input reaches the SQL query text. + severity: ERROR + languages: [go] + mode: taint + metadata: + room_signal: SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT + room_confidence_basis_points: 9000 + pattern-sources: + - pattern-either: + - pattern: $REQ.URL.Query().Get(...) + - pattern: $REQ.FormValue(...) + - pattern: $REQ.PostFormValue(...) + pattern-sinks: + - patterns: + - pattern-either: + - pattern: $DB.Query($QUERY, ...) + - pattern: $DB.QueryContext($CTX, $QUERY, ...) + - pattern: $DB.Exec($QUERY, ...) + - pattern: $DB.ExecContext($CTX, $QUERY, ...) + - focus-metavariable: $QUERY diff --git a/cmd/room-semgrep/main.go b/cmd/room-semgrep/main.go new file mode 100644 index 0000000..9850581 --- /dev/null +++ b/cmd/room-semgrep/main.go @@ -0,0 +1,833 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + roomv1 "github.com/haasonsaas/room/gen/go/room/v1" + "go.yaml.in/yaml/v3" + "golang.org/x/sys/unix" +) + +const ( + completeStatus = "ANALYSIS_STATUS_COMPLETE" + partialStatus = "ANALYSIS_STATUS_PARTIAL" + failedStatus = "ANALYSIS_STATUS_FAILED" + unavailableStatus = "ANALYSIS_STATUS_UNAVAILABLE" + maxOutputBytes = 16 << 20 +) + +var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$`) + +type stringList []string + +func (values *stringList) String() string { return strings.Join(*values, ",") } +func (values *stringList) Set(value string) error { + *values = append(*values, value) + return nil +} + +type analyzerRequest struct { + Phase string `json:"phase"` + Content []byte `json:"content"` + ChangedFiles []string `json:"changed_files,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + ConfigSHA256 string `json:"config_sha256"` + InputSHA256 string `json:"input_sha256"` +} + +type analyzerResponse struct { + Phase string `json:"phase"` + Status string `json:"status"` + ChangedFiles []string `json:"changed_files,omitempty"` + CoveredSignals []string `json:"covered_signals"` + Signals []analyzerSignal `json:"signals,omitempty"` + FailureCode string `json:"failure_code,omitempty"` + InputSHA256 string `json:"input_sha256"` +} + +type analyzerSignal struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint"` + Location sourceLocation `json:"location"` + ConfidenceBasisPoints uint32 `json:"confidence_basis_points"` + EvidenceSHA256 string `json:"evidence_sha256"` +} + +type sourceLocation struct { + FilePath string `json:"file_path"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` +} + +type semgrepReport struct { + Version string `json:"version"` + Results *[]semgrepResult `json:"results"` + Errors *[]json.RawMessage `json:"errors"` + Paths *semgrepPaths `json:"paths"` + SkippedRules *[]json.RawMessage `json:"skipped_rules"` +} + +type semgrepPaths struct { + Scanned *[]string `json:"scanned"` + Skipped *[]json.RawMessage `json:"skipped"` +} + +type semgrepResult struct { + CheckID string `json:"check_id"` + Path string `json:"path"` + Start struct { + Line int `json:"line"` + } `json:"start"` + End struct { + Line int `json:"line"` + } `json:"end"` + Extra struct { + Metadata map[string]any `json:"metadata"` + } `json:"extra"` +} + +type diffArtifact struct { + added map[string]map[int]bool + expected map[string]map[int]string + files map[string]bool + deleted map[string]bool + postimage map[string]bool +} + +func (artifact diffArtifact) paths() []string { + paths := make([]string, 0, len(artifact.files)) + for path := range artifact.files { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +type snapshot struct { + directory string + config string + targetsFile string + targets []string +} + +type adapter struct { + semgrepCore string + config string + repositoryRoot string + covered []string + coveredSet map[string]bool +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("room-semgrep", flag.ContinueOnError) + flags.SetOutput(stderr) + semgrepCore := flags.String("semgrep-core", "", "absolute path to the semgrep-core executable") + config := flags.String("config", "", "absolute path to one local Semgrep rules file") + repositoryRoot := flags.String("repository-root", "", "absolute repository checkout that Semgrep may scan") + var covered stringList + flags.Var(&covered, "covered-signal", "Room SignalKind covered by the configured rules; repeat for each signal") + if err := flags.Parse(args); err != nil { + return 2 + } + adapter, err := newAdapter(*semgrepCore, *config, *repositoryRoot, covered) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + request, err := decodeRequest(stdin) + if err != nil { + fmt.Fprintln(stderr, "decode Room analyzer request:", err) + return 1 + } + response := adapter.analyze(context.Background(), request) + if err := json.NewEncoder(stdout).Encode(response); err != nil { + fmt.Fprintln(stderr, "encode Room analyzer response:", err) + return 1 + } + return 0 +} + +func newAdapter(semgrepCore, config, repositoryRoot string, covered []string) (*adapter, error) { + for name, value := range map[string]string{"semgrep-core executable": semgrepCore, "Semgrep config": config, "repository root": repositoryRoot} { + if value == "" || !filepath.IsAbs(value) { + return nil, fmt.Errorf("%s must be an absolute path", name) + } + } + configInfo, err := os.Stat(config) + if err != nil || !configInfo.Mode().IsRegular() { + return nil, errors.New("Semgrep config must be a regular file") + } + rootInfo, err := os.Stat(repositoryRoot) + if err != nil || !rootInfo.IsDir() { + return nil, errors.New("repository root must be a directory") + } + if len(covered) == 0 { + return nil, errors.New("at least one --covered-signal is required") + } + coveredSet := make(map[string]bool, len(covered)) + for _, signal := range covered { + value, known := roomv1.SignalKind_value[signal] + if !known || roomv1.SignalKind(value) == roomv1.SignalKind_SIGNAL_KIND_UNSPECIFIED || coveredSet[signal] { + return nil, fmt.Errorf("invalid or duplicate covered signal %q", signal) + } + coveredSet[signal] = true + } + configData, err := os.ReadFile(config) + if err != nil { + return nil, errors.New("read Semgrep config") + } + if err := validateRuleCoverage(configData, coveredSet); err != nil { + return nil, err + } + sort.Strings(covered) + return &adapter{semgrepCore: semgrepCore, config: config, repositoryRoot: repositoryRoot, covered: covered, coveredSet: coveredSet}, nil +} + +func validateRuleCoverage(config []byte, covered map[string]bool) error { + var document struct { + Rules []struct { + ID string `yaml:"id"` + Metadata map[string]any `yaml:"metadata"` + } `yaml:"rules"` + } + if err := yaml.Unmarshal(config, &document); err != nil { + return errors.New("Semgrep config is not valid YAML") + } + implemented := make(map[string]bool, len(covered)) + ruleIDs := make(map[string]bool, len(document.Rules)) + for _, rule := range document.Rules { + if rule.ID == "" || ruleIDs[rule.ID] { + return errors.New("Semgrep rules must have unique non-empty IDs") + } + ruleIDs[rule.ID] = true + rawSignal, policyBearing := rule.Metadata["room_signal"] + if !policyBearing { + continue + } + signal, ok := rawSignal.(string) + if !ok || !covered[signal] { + return fmt.Errorf("Semgrep rule %q has an invalid or undeclared room_signal", rule.ID) + } + confidence, ok := yamlConfidence(rule.Metadata["room_confidence_basis_points"]) + if !ok || confidence == 0 || confidence > 10000 { + return fmt.Errorf("Semgrep rule %q has invalid Room confidence", rule.ID) + } + implemented[signal] = true + } + for signal := range covered { + if !implemented[signal] { + return fmt.Errorf("Semgrep config has no rule for covered signal %q", signal) + } + } + return nil +} + +func yamlConfidence(value any) (uint32, bool) { + switch value := value.(type) { + case int: + return uint32(value), value >= 0 + case uint32: + return value, true + case uint64: + return uint32(value), value <= uint64(^uint32(0)) + default: + return 0, false + } +} + +func decodeRequest(input io.Reader) (analyzerRequest, error) { + var request analyzerRequest + decoder := json.NewDecoder(io.LimitReader(input, 8<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return request, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return request, errors.New("request must contain exactly one JSON object") + } + return request, nil +} + +func (a *adapter) analyze(ctx context.Context, request analyzerRequest) analyzerResponse { + digest := sha256.Sum256(request.Content) + inputDigest := hex.EncodeToString(digest[:]) + response := analyzerResponse{Phase: request.Phase, Status: failedStatus, CoveredSignals: []string{}, InputSHA256: inputDigest} + if !strings.EqualFold(request.InputSHA256, inputDigest) { + response.FailureCode = "input_digest_mismatch" + return response + } + if request.Phase == "ANALYSIS_PHASE_PLAN" { + response.Status = partialStatus + response.FailureCode = "semgrep_requires_diff" + return response + } + if request.Phase != "ANALYSIS_PHASE_DIFF" { + response.FailureCode = "input_phase_invalid" + return response + } + + artifact, err := parseDiff(request.Content) + if err != nil { + response.FailureCode = "diff_invalid" + return response + } + if !a.configMatches(request.ConfigSHA256) { + response.FailureCode = "config_digest_mismatch" + return response + } + snapshot, err := a.createSnapshot(request, artifact) + if err != nil { + response.FailureCode = "snapshot_invalid" + return response + } + defer os.RemoveAll(filepath.Dir(snapshot.directory)) + report, status, failure := a.scan(ctx, snapshot) + if failure != "" { + response.Status, response.FailureCode = status, failure + return response + } + if len(artifact.added) == 0 { + if len(*report.Results) != 0 { + response.FailureCode = "semgrep_result_invalid" + return response + } + response.Status = completeStatus + response.ChangedFiles = artifact.paths() + response.CoveredSignals = append([]string(nil), a.covered...) + return response + } + response.Status = completeStatus + response.ChangedFiles = artifact.paths() + response.CoveredSignals = append([]string(nil), a.covered...) + for _, result := range *report.Results { + if result.CheckID == "" || result.Path == "" || result.Start.Line < 1 || result.End.Line < result.Start.Line { + return failed(response, "semgrep_result_invalid") + } + path, err := normalizedResultPath(snapshot.directory, result.Path) + if err != nil { + return failed(response, "semgrep_result_invalid") + } + if index := sort.SearchStrings(snapshot.targets, path); index >= len(snapshot.targets) || snapshot.targets[index] != path { + return failed(response, "semgrep_result_invalid") + } + if !rangeIntersects(artifact.added[path], result.Start.Line, result.End.Line) { + continue + } + rawSignal, present := result.Extra.Metadata["room_signal"] + if !present { + continue + } + signal, ok := rawSignal.(string) + if !ok || signal == "" { + return failed(response, "semgrep_signal_invalid") + } + if !a.coveredSet[signal] { + return failed(response, "semgrep_signal_not_covered") + } + confidence, ok := metadataConfidence(result.Extra.Metadata) + if !ok { + return failed(response, "semgrep_confidence_invalid") + } + evidence, err := evidenceDigest(snapshot.directory, path, result.Start.Line, result.End.Line) + if err != nil { + return failed(response, "semgrep_result_invalid") + } + fingerprint := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s:%x", signal, result.CheckID, path, evidence))) + response.Signals = append(response.Signals, analyzerSignal{ + Kind: signal, Fingerprint: hex.EncodeToString(fingerprint[:]), + Location: sourceLocation{FilePath: path, StartLine: int32(result.Start.Line), EndLine: int32(result.End.Line)}, + ConfidenceBasisPoints: confidence, EvidenceSHA256: hex.EncodeToString(evidence[:]), + }) + } + sort.Slice(response.Signals, func(i, j int) bool { return response.Signals[i].Fingerprint < response.Signals[j].Fingerprint }) + return response +} + +func failed(response analyzerResponse, code string) analyzerResponse { + response.Status, response.CoveredSignals, response.Signals, response.FailureCode = failedStatus, []string{}, nil, code + return response +} + +func (a *adapter) configMatches(expected string) bool { + config, err := os.ReadFile(a.config) + if err != nil { + return false + } + digest := sha256.Sum256(config) + return strings.EqualFold(expected, hex.EncodeToString(digest[:])) +} + +func (a *adapter) createSnapshot(request analyzerRequest, artifact diffArtifact) (snapshot, error) { + root, err := filepath.EvalSymlinks(a.repositoryRoot) + if err != nil { + return snapshot{}, err + } + workingDirectory, err := filepath.EvalSymlinks(request.WorkingDirectory) + if err != nil { + return snapshot{}, errors.New("working directory does not match repository root") + } + rootFD, err := unix.Open(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return snapshot{}, errors.New("repository root cannot be opened safely") + } + defer unix.Close(rootFD) + workingFD, err := unix.Open(workingDirectory, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return snapshot{}, errors.New("working directory cannot be opened safely") + } + defer unix.Close(workingFD) + var rootStat, workingStat unix.Stat_t + if unix.Fstat(rootFD, &rootStat) != nil || unix.Fstat(workingFD, &workingStat) != nil || rootStat.Dev != workingStat.Dev || rootStat.Ino != workingStat.Ino { + return snapshot{}, errors.New("working directory does not match repository root") + } + config, err := os.ReadFile(a.config) + if err != nil { + return snapshot{}, err + } + configDigest := sha256.Sum256(config) + if !strings.EqualFold(request.ConfigSHA256, hex.EncodeToString(configDigest[:])) { + return snapshot{}, errors.New("Semgrep config digest changed") + } + temporary, err := os.MkdirTemp("", "room-semgrep-*") + if err != nil { + return snapshot{}, err + } + cleanup := true + defer func() { + if cleanup { + _ = os.RemoveAll(temporary) + } + }() + repository := filepath.Join(temporary, "repository") + if err := os.Mkdir(repository, 0o700); err != nil { + return snapshot{}, err + } + configPath := filepath.Join(temporary, "rules.yml") + if err := os.WriteFile(configPath, config, 0o600); err != nil { + return snapshot{}, err + } + for path := range artifact.deleted { + if err := requireMissingBeneath(rootFD, path); err != nil { + return snapshot{}, err + } + } + postimages := make(map[string][]byte, len(artifact.postimage)) + for path := range artifact.postimage { + data, err := readRegularBeneath(rootFD, path) + if err != nil { + return snapshot{}, err + } + if err := verifyPostimage(data, artifact.expected[path]); err != nil { + return snapshot{}, err + } + postimages[path] = data + } + targets := make([]string, 0, len(artifact.added)) + for path := range artifact.added { + if !validRelativePath(path) { + return snapshot{}, errors.New("diff path is invalid") + } + destination := filepath.Join(repository, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return snapshot{}, err + } + if err := os.WriteFile(destination, postimages[path], 0o600); err != nil { + return snapshot{}, err + } + targets = append(targets, path) + } + sort.Strings(targets) + targetsPath := filepath.Join(temporary, "targets.json") + targetManifest := []any{"Scanning_roots", map[string]any{ + "root_paths": targets, + "targeting_conf": map[string]any{ + "exclude": []string{}, "max_target_bytes": 0, + "respect_gitignore": false, "respect_semgrepignore_files": false, + "always_select_explicit_targets": true, "explicit_targets": targets, + "force_novcs_project": true, "exclude_minified_files": false, + }, + }} + targetJSON, err := json.Marshal(targetManifest) + if err != nil { + return snapshot{}, err + } + if err := os.WriteFile(targetsPath, targetJSON, 0o600); err != nil { + return snapshot{}, err + } + cleanup = false + return snapshot{directory: repository, config: configPath, targetsFile: targetsPath, targets: targets}, nil +} + +func readRegularBeneath(rootFD int, path string) ([]byte, error) { + fileFD, err := unix.Openat2(rootFD, filepath.FromSlash(path), &unix.OpenHow{ + Flags: unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_SYMLINKS, + }) + if err != nil { + return nil, errors.New("diff target cannot be opened safely") + } + file := os.NewFile(uintptr(fileFD), path) + defer file.Close() + var before, after unix.Stat_t + if err := unix.Fstat(fileFD, &before); err != nil || before.Mode&unix.S_IFMT != unix.S_IFREG || before.Size < 0 || before.Size > 64<<20 { + return nil, errors.New("diff target must be a regular file of at most 64 MiB") + } + data, err := io.ReadAll(io.LimitReader(file, before.Size+1)) + if err != nil || int64(len(data)) != before.Size { + return nil, errors.New("diff target changed while being read") + } + if err := unix.Fstat(fileFD, &after); err != nil || before.Ino != after.Ino || before.Size != after.Size || before.Mtim != after.Mtim || before.Ctim != after.Ctim { + return nil, errors.New("diff target changed while being read") + } + return data, nil +} + +func requireMissingBeneath(rootFD int, path string) error { + fileFD, err := unix.Openat2(rootFD, filepath.FromSlash(path), &unix.OpenHow{ + Flags: unix.O_PATH | unix.O_CLOEXEC | unix.O_NOFOLLOW, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_SYMLINKS, + }) + if err == nil { + _ = unix.Close(fileFD) + return errors.New("deleted diff target still exists") + } + if errors.Is(err, unix.ENOENT) { + return nil + } + return errors.New("deleted diff target cannot be verified safely") +} + +func (a *adapter) scan(ctx context.Context, snapshot snapshot) (semgrepReport, string, string) { + args := []string{"-json_nodots", "-strict", "-rules", snapshot.config, "-targets", snapshot.targetsFile, "-j", "1", "-timeout", "0", "-timeout_threshold", "0", "-max_memory", "0"} + command := exec.CommandContext(ctx, a.semgrepCore, args...) + command.Dir = snapshot.directory + var stdout, stderr limitedBuffer + stdout.limit, stderr.limit = maxOutputBytes, 1<<20 + command.Stdout, command.Stderr = &stdout, &stderr + if err := command.Run(); err != nil { + var execError *exec.Error + if errors.As(err, &execError) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { + return semgrepReport{}, unavailableStatus, "semgrep_unavailable" + } + return semgrepReport{}, failedStatus, "semgrep_failed" + } + if stdout.overflow { + return semgrepReport{}, failedStatus, "semgrep_output_too_large" + } + var report semgrepReport + decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) + if err := decoder.Decode(&report); err != nil || !validReportShape(report) { + return semgrepReport{}, failedStatus, "semgrep_report_invalid" + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return semgrepReport{}, failedStatus, "semgrep_report_invalid" + } + if !sameTargets(snapshot.directory, snapshot.targets, *report.Paths.Scanned) { + return semgrepReport{}, failedStatus, "semgrep_targets_incomplete" + } + return report, "", "" +} + +func validReportShape(report semgrepReport) bool { + return report.Version != "" && report.Results != nil && report.Errors != nil && len(*report.Errors) == 0 && + report.Paths != nil && report.Paths.Scanned != nil && (report.Paths.Skipped == nil || len(*report.Paths.Skipped) == 0) && + report.SkippedRules != nil && len(*report.SkippedRules) == 0 +} + +func sameTargets(directory string, expected, scanned []string) bool { + normalized := make([]string, 0, len(scanned)) + for _, path := range scanned { + path, err := normalizedResultPath(directory, path) + if err != nil { + return false + } + normalized = append(normalized, path) + } + sort.Strings(normalized) + return fmt.Sprint(expected) == fmt.Sprint(normalized) +} + +type limitedBuffer struct { + bytes.Buffer + limit int + overflow bool +} + +func (buffer *limitedBuffer) Write(value []byte) (int, error) { + written := len(value) + remaining := buffer.limit - buffer.Len() + if remaining <= 0 { + buffer.overflow = true + return written, nil + } + if len(value) > remaining { + value = value[:remaining] + buffer.overflow = true + } + _, _ = buffer.Buffer.Write(value) + return written, nil +} + +func parseDiff(diff []byte) (diffArtifact, error) { + artifact := diffArtifact{added: make(map[string]map[int]bool), expected: make(map[string]map[int]string), files: make(map[string]bool), deleted: make(map[string]bool), postimage: make(map[string]bool)} + if len(diff) == 0 { + return artifact, nil + } + path, oldPath, sectionPath, newLine := "", "", "", 0 + oldRemaining, newRemaining := 0, 0 + inHunk, sawSection, sawOld, sawTarget, sawHunk := false, false, false, false, false + for _, raw := range bytes.Split(diff, []byte("\n")) { + line := string(raw) + if inHunk && oldRemaining == 0 && newRemaining == 0 { + inHunk = false + } + if !inHunk { + if strings.HasPrefix(line, "diff --git ") { + if sawSection && !sawHunk { + return diffArtifact{}, errors.New("diff section has no hunks") + } + fields := strings.Fields(line) + if len(fields) != 4 { + return diffArtifact{}, errors.New("diff header is invalid") + } + oldHeaderPath, oldOK := parseSidePath(fields[2], "a/") + newHeaderPath, newOK := parseSidePath(fields[3], "b/") + if !oldOK || !newOK || oldHeaderPath != newHeaderPath { + return diffArtifact{}, errors.New("renames and malformed diff headers are unsupported") + } + path, oldPath, sectionPath, sawOld, sawTarget, sawHunk = "", "", oldHeaderPath, false, false, false + sawSection = true + continue + } + if strings.HasPrefix(line, "--- ") { + if !sawSection || sawOld { + return diffArtifact{}, errors.New("source header is invalid") + } + oldPath, _ = parseSidePath(strings.TrimPrefix(line, "--- "), "a/") + if oldPath == "" && line != "--- /dev/null" { + return diffArtifact{}, errors.New("source path is invalid") + } + if oldPath != "" && oldPath != sectionPath { + return diffArtifact{}, errors.New("source path does not match diff header") + } + sawOld = true + continue + } + if strings.HasPrefix(line, "+++ ") { + if !sawOld || sawTarget { + return diffArtifact{}, errors.New("target header is invalid") + } + targetDeleted := line == "+++ /dev/null" + path, _ = parseSidePath(strings.TrimPrefix(line, "+++ "), "b/") + sawTarget = true + if path == "" && line != "+++ /dev/null" { + return diffArtifact{}, errors.New("target path is invalid") + } + if path == "" { + path = oldPath + } + if path != sectionPath || (oldPath == "" && targetDeleted) { + return diffArtifact{}, errors.New("target path does not match diff header") + } + if !validRelativePath(path) || artifact.files[path] { + return diffArtifact{}, errors.New("target path is invalid or duplicated") + } + artifact.files[path] = true + if targetDeleted { + artifact.deleted[path] = true + } else { + artifact.postimage[path] = true + } + continue + } + if match := hunkHeader.FindStringSubmatch(line); match != nil { + if !sawTarget { + return diffArtifact{}, errors.New("hunk has no target") + } + var err error + oldRemaining, err = hunkCount(match[2]) + if err != nil { + return diffArtifact{}, errors.New("hunk count is invalid") + } + newLine, err = strconv.Atoi(match[3]) + if err != nil { + return diffArtifact{}, errors.New("hunk line is invalid") + } + newRemaining, err = hunkCount(match[4]) + if err != nil { + return diffArtifact{}, errors.New("hunk count is invalid") + } + inHunk, sawHunk = true, true + continue + } + if line == "" || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file mode ") || strings.HasPrefix(line, "deleted file mode ") || strings.HasPrefix(line, "old mode ") || strings.HasPrefix(line, "new mode ") { + continue + } + return diffArtifact{}, errors.New("unexpected diff content") + } + if line == `\ No newline at end of file` { + continue + } + if line == "" { + return diffArtifact{}, errors.New("truncated hunk") + } + switch line[0] { + case ' ': + if oldRemaining == 0 || newRemaining == 0 || path == "" { + return diffArtifact{}, errors.New("invalid context line") + } + setExpected(artifact.expected, path, newLine, line[1:]) + oldRemaining-- + newRemaining-- + newLine++ + case '+': + if newRemaining == 0 || path == "" { + return diffArtifact{}, errors.New("invalid added line") + } + setExpected(artifact.expected, path, newLine, line[1:]) + if artifact.added[path] == nil { + artifact.added[path] = make(map[int]bool) + } + artifact.added[path][newLine] = true + newRemaining-- + newLine++ + case '-': + if oldRemaining == 0 { + return diffArtifact{}, errors.New("invalid removed line") + } + oldRemaining-- + default: + return diffArtifact{}, errors.New("invalid hunk line") + } + } + if inHunk && (oldRemaining != 0 || newRemaining != 0) { + return diffArtifact{}, errors.New("truncated hunk") + } + if !sawHunk || !sawSection { + return diffArtifact{}, errors.New("no unified diff hunks") + } + return artifact, nil +} + +func hunkCount(value string) (int, error) { + if value == "" { + return 1, nil + } + count, err := strconv.Atoi(value) + return count, err +} + +func setExpected(expected map[string]map[int]string, path string, line int, content string) { + if expected[path] == nil { + expected[path] = make(map[int]string) + } + expected[path][line] = content +} + +func verifyPostimage(data []byte, expected map[int]string) error { + lines := bytes.Split(data, []byte("\n")) + for line, content := range expected { + if line < 1 || line > len(lines) || string(lines[line-1]) != content { + return errors.New("diff does not match repository postimage") + } + } + return nil +} + +func parseSidePath(value, prefix string) (string, bool) { + value = strings.SplitN(value, "\t", 2)[0] + if !strings.HasPrefix(value, prefix) || strings.HasPrefix(value, `"`) || strings.TrimSpace(value) != value { + return "", false + } + path := strings.TrimPrefix(value, prefix) + return path, validRelativePath(path) +} + +func validRelativePath(path string) bool { + clean := filepath.ToSlash(filepath.Clean(path)) + return path != "" && !filepath.IsAbs(path) && clean == path && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func normalizedResultPath(directory, path string) (string, error) { + if filepath.IsAbs(path) { + relative, err := filepath.Rel(directory, path) + if err != nil || strings.HasPrefix(filepath.ToSlash(relative), "../") { + return "", errors.New("result path outside snapshot") + } + path = relative + } + path = filepath.ToSlash(filepath.Clean(path)) + if !validRelativePath(path) { + return "", errors.New("result path invalid") + } + return path, nil +} + +func rangeIntersects(lines map[int]bool, start, end int) bool { + if start < 1 || end < start { + return false + } + for line := start; line <= end; line++ { + if lines[line] { + return true + } + } + return false +} + +func evidenceDigest(directory, path string, start, end int) ([sha256.Size]byte, error) { + data, err := os.ReadFile(filepath.Join(directory, filepath.FromSlash(path))) + if err != nil { + return [sha256.Size]byte{}, err + } + lines := bytes.Split(data, []byte("\n")) + if start < 1 || end < start || end > len(lines) { + return [sha256.Size]byte{}, errors.New("finding range outside target") + } + return sha256.Sum256(bytes.Join(lines[start-1:end], []byte("\n"))), nil +} + +func metadataConfidence(metadata map[string]any) (uint32, bool) { + value, ok := metadata["room_confidence_basis_points"] + if !ok { + return 0, false + } + var confidence uint64 + switch typed := value.(type) { + case float64: + if typed < 0 || typed != float64(uint64(typed)) { + return 0, false + } + confidence = uint64(typed) + case string: + parsed, err := strconv.ParseUint(typed, 10, 32) + if err != nil { + return 0, false + } + confidence = parsed + default: + return 0, false + } + return uint32(confidence), confidence > 0 && confidence <= 10_000 +} diff --git a/cmd/room-semgrep/main_test.go b/cmd/room-semgrep/main_test.go new file mode 100644 index 0000000..6bc9d0e --- /dev/null +++ b/cmd/room-semgrep/main_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +const sqlSignal = "SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT" + +const testRules = `rules: + - id: room.test + metadata: + room_signal: SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT + room_confidence_basis_points: 9000 +` + +func TestAdapterMapsSemgrepMetadataAndFiltersToAddedLines(t *testing.T) { + _, repository := workspace(t) + if err := os.WriteFile(filepath.Join(repository, "handler.go"), []byte(strings.Repeat("\n", 7)+"db.Query(query)\n"), 0o600); err != nil { + t.Fatal(err) + } + report := `{ + "version":"1.139.0", + "errors":[], + "paths":{"scanned":["handler.go"],"skipped":[]}, + "skipped_rules":[], + "results":[ + {"check_id":"old","path":"handler.go","start":{"line":3},"end":{"line":3},"extra":{"metadata":{"room_signal":"SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","room_confidence_basis_points":9000}}}, + {"check_id":"new","path":"handler.go","start":{"line":8},"end":{"line":8},"extra":{"metadata":{"room_signal":"SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT","room_confidence_basis_points":9000}}} + ] +}` + semgrep := fakeSemgrep(t, report, 0) + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter(semgrep, config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/handler.go b/handler.go\n--- a/handler.go\n+++ b/handler.go\n@@ -7,0 +8 @@\n+db.Query(query)\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + + if response.Status != completeStatus || fmt.Sprint(response.CoveredSignals) != "["+sqlSignal+"]" { + t.Fatalf("response = %+v", response) + } + if len(response.Signals) != 1 || response.Signals[0].Kind != sqlSignal || response.Signals[0].Location.FilePath != "handler.go" || response.Signals[0].Location.StartLine != 8 || response.Signals[0].ConfidenceBasisPoints != 9000 { + t.Fatalf("signals = %+v", response.Signals) + } + if response.Signals[0].Fingerprint == "" || len(response.Signals[0].EvidenceSHA256) != 64 { + t.Fatalf("signal lacks receipt hashes: %+v", response.Signals[0]) + } +} + +func TestAdapterReturnsPartialForPlansWithoutRunningSemgrep(t *testing.T) { + root, repository := workspace(t) + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter("/missing/semgrep", config, root, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + content := []byte("Build a query endpoint") + response := adapter.analyze(t.Context(), requestForPhase(repository, content, "ANALYSIS_PHASE_PLAN")) + if response.Status != partialStatus || response.FailureCode != "semgrep_requires_diff" || len(response.CoveredSignals) != 0 { + t.Fatalf("response = %+v", response) + } +} + +func TestAdapterRejectsWorkspaceOutsideConfiguredRoot(t *testing.T) { + _, repository := workspace(t) + outside := t.TempDir() + config := writeFile(t, "rules.yml", testRules) + diff := []byte("diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -0,0 +1 @@\n+package main\n") + if err := os.WriteFile(filepath.Join(outside, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter, err := newAdapter("/missing/semgrep", config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + response := adapter.analyze(t.Context(), requestFor(outside, config, diff)) + if response.Status != failedStatus || response.FailureCode != "snapshot_invalid" { + t.Fatalf("response = %+v", response) + } +} + +func TestAddedLinesTreatsSourceBeginningWithPlusAsHunkContent(t *testing.T) { + diff := []byte("diff --git a/value.go b/value.go\n--- a/value.go\n+++ b/value.go\n@@ -0,0 +1 @@\n+++counter\n") + artifact, err := parseDiff(diff) + if err != nil { + t.Fatal(err) + } + if !artifact.added["value.go"][1] { + t.Fatalf("added lines = %+v", artifact.added) + } +} + +func TestAdapterRejectsMalformedSemgrepResult(t *testing.T) { + _, repository := workspace(t) + if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + semgrep := fakeSemgrep(t, `{"version":"1","errors":[],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[{"check_id":"bad","path":"main.go","start":{"line":0},"end":{"line":0}}]}`, 0) + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter(semgrep, config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -0,0 +1 @@\n+package main\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != failedStatus || response.FailureCode != "semgrep_result_invalid" { + t.Fatalf("response = %+v", response) + } +} + +func TestAdapterFailsClosedForIncompleteOrSkippedScans(t *testing.T) { + tests := []struct { + name, report, code string + }{ + {"target missing", `{"version":"1","errors":[],"paths":{"scanned":[],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_targets_incomplete"}, + {"target skipped", `{"version":"1","errors":[],"paths":{"scanned":["main.go"],"skipped":[{"path":"main.go"}]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, + {"scan error", `{"version":"1","errors":[{"message":"parse failed"}],"paths":{"scanned":["main.go"],"skipped":[]},"skipped_rules":[],"results":[]}`, "semgrep_report_invalid"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, repository := workspace(t) + if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter(fakeSemgrep(t, tt.report, 0), config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/main.go b/main.go\n--- /dev/null\n+++ b/main.go\n@@ -0,0 +1 @@\n+package main\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.Status != failedStatus || response.FailureCode != tt.code { + t.Fatalf("response = %+v", response) + } + }) + } +} + +func TestAdapterBindsConfigAndSourcePostimage(t *testing.T) { + _, repository := workspace(t) + if err := os.WriteFile(filepath.Join(repository, "main.go"), []byte("package changed\n"), 0o600); err != nil { + t.Fatal(err) + } + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter("/missing/semgrep", config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/main.go b/main.go\n--- /dev/null\n+++ b/main.go\n@@ -0,0 +1 @@\n+package main\n") + response := adapter.analyze(t.Context(), requestFor(repository, config, diff)) + if response.FailureCode != "snapshot_invalid" { + t.Fatalf("source mismatch response = %+v", response) + } + + deletion := []byte("diff --git a/old.go b/old.go\n--- a/old.go\n+++ /dev/null\n@@ -1 +0,0 @@\n-package old\n") + request := requestFor(repository, config, deletion) + if err := os.WriteFile(config, []byte(testRules+"# changed\n"), 0o600); err != nil { + t.Fatal(err) + } + response = adapter.analyze(t.Context(), request) + if response.FailureCode != "config_digest_mismatch" { + t.Fatalf("config mismatch response = %+v", response) + } +} + +func TestAdapterRejectsSymlinkTargets(t *testing.T) { + _, repository := workspace(t) + realFile := filepath.Join(repository, "real.go") + if err := os.WriteFile(realFile, []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realFile, filepath.Join(repository, "main.go")); err != nil { + t.Fatal(err) + } + config := writeFile(t, "rules.yml", testRules) + adapter, err := newAdapter("/missing/semgrep", config, repository, []string{sqlSignal}) + if err != nil { + t.Fatal(err) + } + diff := []byte("diff --git a/main.go b/main.go\n--- /dev/null\n+++ b/main.go\n@@ -0,0 +1 @@\n+package main\n") + if response := adapter.analyze(t.Context(), requestFor(repository, config, diff)); response.FailureCode != "snapshot_invalid" { + t.Fatalf("response = %+v", response) + } +} + +func TestAdapterRejectsUnimplementedCoverageAndTraversalDiff(t *testing.T) { + _, repository := workspace(t) + emptyConfig := writeFile(t, "empty.yml", "rules: []\n") + if _, err := newAdapter("/missing/semgrep", emptyConfig, repository, []string{sqlSignal}); err == nil { + t.Fatal("expected empty ruleset to be rejected") + } + diff := []byte("diff --git a/x b/x\n--- a/../../x\n+++ /dev/null\n@@ -1 +0,0 @@\n-secret\n") + if _, err := parseDiff(diff); err == nil { + t.Fatal("expected traversal diff to be rejected") + } +} + +func workspace(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + repository := filepath.Join(root, "repo") + if err := os.Mkdir(repository, 0o700); err != nil { + t.Fatal(err) + } + return root, repository +} + +func requestFor(repository, config string, content []byte) analyzerRequest { + request := requestForPhase(repository, content, "ANALYSIS_PHASE_DIFF") + configData, _ := os.ReadFile(config) + configDigest := sha256.Sum256(configData) + request.ConfigSHA256 = hex.EncodeToString(configDigest[:]) + return request +} + +func requestForPhase(repository string, content []byte, phase string) analyzerRequest { + digest := sha256.Sum256(content) + return analyzerRequest{Phase: phase, Content: content, WorkingDirectory: repository, InputSHA256: hex.EncodeToString(digest[:])} +} + +func fakeSemgrep(t *testing.T, report string, exitCode int) string { + t.Helper() + return writeExecutable(t, "semgrep", fmt.Sprintf("#!/bin/sh\nprintf '%%s' '%s'\nexit %d\n", report, exitCode)) +} + +func writeExecutable(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o700); err != nil { + t.Fatal(err) + } + return path +} + +func writeFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestRunEmitsStrictProviderJSON(t *testing.T) { + _, repository := workspace(t) + content := []byte("plan") + request := requestForPhase(repository, content, "ANALYSIS_PHASE_PLAN") + encoded, _ := json.Marshal(request) + var stdout, stderr bytes.Buffer + config := writeFile(t, "rules.yml", testRules) + args := []string{"--semgrep-core", "/missing/semgrep-core", "--config", config, "--repository-root", repository, "--covered-signal", sqlSignal} + if code := run(args, bytes.NewReader(encoded), &stdout, &stderr); code != 0 { + t.Fatalf("run exit %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"status":"ANALYSIS_STATUS_PARTIAL"`) { + t.Fatalf("stdout = %s", stdout.String()) + } +} diff --git a/docs/analyzer.md b/docs/analyzer.md index 6ccb10a..35059aa 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -2,8 +2,10 @@ Room launches one explicitly configured absolute executable without a shell. The request is strict JSON on stdin and contains the analysis phase, base64 JSON -content bytes, changed files, and the SHA-256 input digest. The executable must -return exactly one JSON object on stdout; unknown or trailing fields are rejected. +content bytes, changed files, working directory, and SHA-256 input digest. The +executable must return exactly one JSON object on stdout; unknown or trailing +fields are rejected. The working directory is caller-supplied and must be +restricted by analyzers that read files. The response declares the same phase and input digest, a status, complete covered-signal names, and zero or more typed signals. It may also declare @@ -43,3 +45,50 @@ Coverage is mandatory and uses exact `SignalKind` enum names. This prevents a specialized analyzer from being treated as authoritative for checks it does not implement. Rules that require signals outside the configured coverage evaluate as indeterminate. + +## Semgrep adapter + +`cmd/room-semgrep` runs Semgrep Community Edition against files named by a +unified diff. It requires three absolute paths: + +- `--semgrep-core`: the `semgrep-core` executable included with Semgrep CE. +- `--config`: local rules file. +- `--repository-root`: the one repository eligible for scanning. It must match + the request working directory after resolving symlinks. + +The Linux adapter opens the repository once and uses `openat2` to confine target +reads beneath it. Target symlinks, special files, files larger than 64 MiB, +renames, binary patches, and malformed or incomplete unified diffs produce a +failed receipt. The adapter invokes `semgrep-core` with explicit snapshotted +targets, strict rule validation, disabled timeouts, and no ignore-file target +discovery. Any core parser error, target skip, or rule skip fails the receipt. +Registry configuration URLs are not accepted. + +Each policy-bearing Semgrep rule must provide these metadata fields: + +```yaml +metadata: + room_signal: SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT + room_confidence_basis_points: 9000 +``` + +Pass every metadata signal through a repeated `--covered-signal` argument and +list the same signals in `ROOM_ANALYZER_COVERED_SIGNALS`. A finding that names an +undeclared signal or an invalid confidence produces a failed receipt. Findings +without `room_signal` metadata are ignored. + +```bash +go build -o ~/.local/bin/room-semgrep ./cmd/room-semgrep + +ROOM_ANALYZER_EXECUTABLE="$HOME/.local/bin/room-semgrep" +ROOM_ANALYZER_ARGS='["--semgrep-core","/absolute/path/to/semgrep-core","--config","/absolute/path/to/room/analyzers/semgrep/room.yml","--repository-root","/srv/repos/my-repository","--covered-signal","SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' +ROOM_ANALYZER_CONFIG_FILE=/absolute/path/to/room/analyzers/semgrep/room.yml +ROOM_ANALYZER_COVERED_SIGNALS='["SIGNAL_KIND_DYNAMIC_SQL_WITH_UNTRUSTED_INPUT"]' +ROOM_ANALYZER_ID=room.semgrep +ROOM_ANALYZER_VERSION=1 +``` + +`ROOM_ANALYZER_CONFIG_FILE` binds the rules file contents to Room's analyzer +identity. Update `ROOM_ANALYZER_VERSION` when the `semgrep-core` binary changes. +The adapter returns `PARTIAL` for plan analysis and does not claim signal +coverage for plans. diff --git a/go.mod b/go.mod index b1a8c47..3cf7365 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/oauth2 v0.35.0 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 8f6227e..a9078d7 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= @@ -43,6 +45,7 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 9c413a6..7c60776 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -27,9 +27,10 @@ const defaultTimeout = 30 * time.Second // Input is the complete artifact passed to an analyzer. type Input struct { - Phase roomv1.AnalysisPhase - Content []byte - ChangedFiles []string + Phase roomv1.AnalysisPhase + Content []byte + ChangedFiles []string + WorkingDirectory string } // Config identifies and launches one analyzer. Args are passed directly to the @@ -138,15 +139,18 @@ func (w *boundedDrainWriter) Write(data []byte) (int, error) { func (w *boundedDrainWriter) Bytes() []byte { return w.buffer.Bytes() } type providerRequest struct { - Phase string `json:"phase"` - Content []byte `json:"content"` - ChangedFiles []string `json:"changed_files,omitempty"` - InputSHA256 string `json:"input_sha256"` + Phase string `json:"phase"` + Content []byte `json:"content"` + ChangedFiles []string `json:"changed_files,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + ConfigSHA256 string `json:"config_sha256"` + InputSHA256 string `json:"input_sha256"` } type providerResponse struct { Phase string `json:"phase"` Status string `json:"status"` + ChangedFiles []string `json:"changed_files,omitempty"` Languages []string `json:"languages,omitempty"` Frameworks []string `json:"frameworks,omitempty"` CoveredSignals []string `json:"covered_signals"` @@ -183,7 +187,8 @@ func (a *externalAnalyzer) Analyze(ctx context.Context, input Input) *roomv1.Ana request := providerRequest{ Phase: input.Phase.String(), Content: input.Content, - ChangedFiles: append([]string(nil), input.ChangedFiles...), InputSHA256: hex.EncodeToString(digest[:]), + ChangedFiles: append([]string(nil), input.ChangedFiles...), WorkingDirectory: input.WorkingDirectory, + ConfigSHA256: hex.EncodeToString(a.identity.GetConfigSha256()), InputSHA256: hex.EncodeToString(digest[:]), } requestJSON, err := json.Marshal(request) if err != nil { @@ -222,6 +227,11 @@ func (a *externalAnalyzer) Analyze(ctx context.Context, input Input) *roomv1.Ana if code != "" { return a.failure(report, roomv1.AnalysisStatus_ANALYSIS_STATUS_INVALID, code, digest[:]) } + changedFiles, code := normalizeChangedFiles(response.ChangedFiles) + if code != "" { + return a.failure(report, roomv1.AnalysisStatus_ANALYSIS_STATUS_INVALID, code, digest[:]) + } + report.Artifact.ChangedFiles = changedFiles report.Artifact.Languages = languages report.Artifact.Frameworks = frameworks report.Status = receipt.Status @@ -250,6 +260,24 @@ func normalizeClassifications(values []string) ([]string, string) { return normalized, "" } +func normalizeChangedFiles(values []string) ([]string, string) { + normalized := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + value := filepath.ToSlash(filepath.Clean(raw)) + if raw == "" || filepath.IsAbs(raw) || value != raw || value == "." || value == ".." || strings.HasPrefix(value, "../") || strings.ContainsAny(value, "\x00\r\n") { + return nil, "changed_files_invalid" + } + if _, duplicate := seen[value]; duplicate { + return nil, "changed_files_invalid" + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + sort.Strings(normalized) + return normalized, "" +} + func decodeProviderResponse(output []byte) (providerResponse, string) { var response providerResponse decoder := json.NewDecoder(bytes.NewReader(output)) diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 5e8b7d3..d879fb5 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -22,6 +22,7 @@ func TestExternalAnalyzerStampsTrustedIdentityAndArtifact(t *testing.T) { executable := writeProvider(t, fmt.Sprintf(`{ "phase":"ANALYSIS_PHASE_DIFF", "status":"ANALYSIS_STATUS_COMPLETE", + "changed_files":["api.go"], "languages":[" Go ","SQL"], "frameworks":["ConnectRPC"], "covered_signals":["SIGNAL_KIND_SECRET_LITERAL"], @@ -53,6 +54,9 @@ func TestExternalAnalyzerStampsTrustedIdentityAndArtifact(t *testing.T) { if got := report.GetArtifact().GetPhase(); got != input.Phase { t.Fatalf("phase = %s", got) } + if fmt.Sprint(report.GetArtifact().GetChangedFiles()) != "[api.go]" { + t.Fatalf("changed files = %v", report.GetArtifact().GetChangedFiles()) + } if fmt.Sprint(report.GetArtifact().GetLanguages()) != "[go sql]" || fmt.Sprint(report.GetArtifact().GetFrameworks()) != "[connectrpc]" { t.Fatalf("artifact classification = languages %v frameworks %v", report.GetArtifact().GetLanguages(), report.GetArtifact().GetFrameworks()) } @@ -187,7 +191,7 @@ func TestNewExternalDefaultsAndValidatesOutputLimit(t *testing.T) { } func TestExternalAnalyzerUsesJSONStdinAndLiteralArguments(t *testing.T) { - input := Input{Phase: roomv1.AnalysisPhase_ANALYSIS_PHASE_PLAN, Content: []byte("binary\x00artifact"), ChangedFiles: []string{"one.go", "two.go"}} + input := Input{Phase: roomv1.AnalysisPhase_ANALYSIS_PHASE_PLAN, Content: []byte("binary\x00artifact"), ChangedFiles: []string{"one.go", "two.go"}, WorkingDirectory: "/workspace/repo"} digest := sha256.Sum256(input.Content) temp := t.TempDir() capture := filepath.Join(temp, "request.json") @@ -199,7 +203,9 @@ func TestExternalAnalyzerUsesJSONStdinAndLiteralArguments(t *testing.T) { if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { t.Fatal(err) } - a, err := NewExternal(Config{ID: "boundary", Version: "1", Executable: executable, Args: []string{literalArgument}, CoveredSignals: []roomv1.SignalKind{secretSignal}}) + configBytes := []byte("rules-v4") + wantConfigDigest := sha256.Sum256(configBytes) + a, err := NewExternal(Config{ID: "boundary", Version: "1", Executable: executable, Args: []string{literalArgument}, Config: configBytes, CoveredSignals: []roomv1.SignalKind{secretSignal}}) if err != nil { t.Fatal(err) } @@ -223,6 +229,12 @@ func TestExternalAnalyzerUsesJSONStdinAndLiteralArguments(t *testing.T) { if fmt.Sprint(request.ChangedFiles) != fmt.Sprint(input.ChangedFiles) { t.Fatalf("changed files = %v", request.ChangedFiles) } + if request.WorkingDirectory != input.WorkingDirectory { + t.Fatalf("working directory = %q", request.WorkingDirectory) + } + if request.ConfigSHA256 != hex.EncodeToString(wantConfigDigest[:]) { + t.Fatalf("config digest = %q", request.ConfigSHA256) + } } func TestNewExternalRejectsUnsafeOrIncompleteConfiguration(t *testing.T) { diff --git a/internal/app/service.go b/internal/app/service.go index 2729d17..1cca5fe 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -108,7 +108,7 @@ func (s *Service) PreviewRuleset(ctx context.Context, req *connect.Request[roomv if phaseErr != nil { return nil, connect.NewError(connect.CodeInvalidArgument, phaseErr) } - report := s.analyze(ctx, phase, content, input.GetContext().GetChangedFiles()) + report := s.analyze(ctx, phase, content, input.GetContext().GetChangedFiles(), input.GetContext().GetCwd()) result := s.evaluationPolicy.Evaluate(preview, input.GetContext(), report) return connect.NewResponse(&roomv1.PreviewRulesetResponse{Result: result}), nil } @@ -210,7 +210,7 @@ func (s *Service) evaluate(ctx context.Context, phase roomv1.AnalysisPhase, inpu if input != nil && phase == roomv1.AnalysisPhase_ANALYSIS_PHASE_DIFF { content = input.GetDiff() } - report := s.analyze(ctx, phase, []byte(content), verified.GetChangedFiles()) + report := s.analyze(ctx, phase, []byte(content), verified.GetChangedFiles(), verified.GetCwd()) ruleset := scopedRuleset(s.store.ActiveRuleset(), principal) result := s.evaluationPolicy.Evaluate(ruleset, verified, report) eventID, auditErr := s.store.AppendAudit(evaluationEvent(principal, phase, result)) @@ -754,9 +754,9 @@ func candidateRepository(candidate *roomv1.PolicyCandidate) (string, error) { return repositories[0], nil } -func (s *Service) analyze(ctx context.Context, phase roomv1.AnalysisPhase, content []byte, changedFiles []string) *roomv1.AnalysisReport { +func (s *Service) analyze(ctx context.Context, phase roomv1.AnalysisPhase, content []byte, changedFiles []string, workingDirectory string) *roomv1.AnalysisReport { if s.analyzer != nil { - return s.analyzer.Analyze(ctx, analyzer.Input{Phase: phase, Content: content, ChangedFiles: changedFiles}) + return s.analyzer.Analyze(ctx, analyzer.Input{Phase: phase, Content: content, ChangedFiles: changedFiles, WorkingDirectory: workingDirectory}) } digest := sha256.Sum256(content) return &roomv1.AnalysisReport{ReportId: "unavailable", Status: roomv1.AnalysisStatus_ANALYSIS_STATUS_UNAVAILABLE, Artifact: &roomv1.ArtifactRef{Phase: phase, Sha256: digest[:], ChangedFiles: append([]string(nil), changedFiles...)}}