From 96f8a7d7d7e8a16cf9158988e470552c32c6fca4 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Sat, 12 Sep 2026 13:40:18 +0000 Subject: [PATCH] feat: add runtime sarif and hardening --- README.md | 2 +- cmd/agentctl/main.go | 33 +++++++++- docs/runtime-audit-mvp.md | 4 +- internal/runtime/audit.go | 19 +++--- internal/runtime/ingest.go | 21 +++++++ internal/runtime/priority2_test.go | 54 +++++++++++++++++ internal/runtime/sarif.go | 86 ++++++++++++++++++++++++++ internal/runtime/suppressions.go | 96 ++++++++++++++++++++++++++++++ 8 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 internal/runtime/priority2_test.go create mode 100644 internal/runtime/sarif.go create mode 100644 internal/runtime/suppressions.go diff --git a/README.md b/README.md index 68ffecd..d61bc60 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ The alpha includes ten explainable rules: - Detection is heuristic and should be reviewed by a human. - The alpha scans local repositories and configuration files; GitHub, GitLab, Docker and Kubernetes connectors are not implemented yet. - No runtime proxy, IAM remediation, hosted control plane, dashboard or compliance certification is included. -- JSON and SARIF are the stable report formats for the current slice; CSV and issue export are planned. +- JSON and SARIF are stable report formats for static and runtime findings; CSV and issue export are planned. SARIF is available now. Use `--fail-on high` or `--fail-on critical` to make findings a CI gate; the default `none` keeps scans informational. Use `agentctl --help`, `agentctl scan --help`, or `agentctl init --help` for the supported command surface. diff --git a/cmd/agentctl/main.go b/cmd/agentctl/main.go index b7fa7ac..23320b9 100644 --- a/cmd/agentctl/main.go +++ b/cmd/agentctl/main.go @@ -39,9 +39,11 @@ Scan flags: Runtime audit flags: --source format input source: jsonl, otel-json or api-gateway --fail-on severity return non-zero at this severity or higher - --format format output format: text or json + --format format output format: text, json or sarif + --baseline file suppress findings already present in a runtime JSON report --inventory file static agentctl JSON report to compare with runtime events --output file write the audit report to a file instead of stdout + --suppressions file suppress active runtime findings with reason and expiry The scanner is read-only and metadata-only. It does not execute scanned content. ` @@ -403,7 +405,9 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error { inventoryPath := fs.String("inventory", "", "static agentctl JSON report") format := fs.String("format", "text", "output format: text or json") failOn := fs.String("fail-on", "none", "return a non-zero exit code at this severity or higher") + baseline := fs.String("baseline", "", "suppress findings already present in a runtime JSON report") output := fs.String("output", "", "write the audit report to a file instead of stdout") + suppressions := fs.String("suppressions", "", "suppress active runtime findings with reason and expiry") if err := fs.Parse(args[1:]); err != nil { return err } @@ -413,7 +417,7 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error { if *source != string(runtime.SourceJSONL) && *source != string(runtime.SourceOTelJSON) && *source != string(runtime.SourceAPIGateway) { return fmt.Errorf("unsupported runtime source %q", *source) } - if *format != "text" && *format != "json" { + if *format != "text" && *format != "json" && *format != "sarif" { return fmt.Errorf("unsupported runtime audit format %q", *format) } if !validSeverity(*failOn) { @@ -439,12 +443,37 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error { return fmt.Errorf("decode inventory: %w", err) } audit := runtime.Audit(runtimeReport, inventory) + if *baseline != "" { + baselineFile, err := os.Open(*baseline) + if err != nil { + return fmt.Errorf("open runtime baseline: %w", err) + } + defer baselineFile.Close() + if err := runtime.ApplyBaseline(&audit, baselineFile); err != nil { + return err + } + } + if *suppressions != "" { + suppressionFile, err := os.Open(*suppressions) + if err != nil { + return fmt.Errorf("open runtime suppressions: %w", err) + } + defer suppressionFile.Close() + if err := runtime.ApplySuppressions(&audit, suppressionFile, time.Now()); err != nil { + return err + } + } var payload []byte if *format == "json" { payload, err = json.MarshalIndent(audit, "", " ") if err == nil { payload = append(payload, '\n') } + } else if *format == "sarif" { + payload, err = audit.SARIF() + if err == nil { + payload = append(payload, '\n') + } } else { payload = []byte(runtimeAuditText(audit)) } diff --git a/docs/runtime-audit-mvp.md b/docs/runtime-audit-mvp.md index e7588ae..2e69c04 100644 --- a/docs/runtime-audit-mvp.md +++ b/docs/runtime-audit-mvp.md @@ -90,9 +90,9 @@ Runtime-аудит должен быть **наблюдателем, а не и В репозитории реализован библиотечный слой `internal/runtime`, который выполняет безопасное чтение нормализованных JSONL-событий, детерминированную агрегацию и сопоставление со статическим `scan.Report`. Добавлены правила `ACP-R001` — `ACP-R004`, включая обнаружение undeclared target, фактического provider, production write activity и неоднозначного сопоставления агента. -Добавлена команда `agentctl runtime-audit --inventory `. Флаг `--source` выбирает `jsonl`, `otel-json` или `api-gateway`. Команда поддерживает text/json output, атомарную запись через существующий механизм `--output` и CI-порог `--fail-on`. +Добавлена команда `agentctl runtime-audit --inventory `. Флаг `--source` выбирает `jsonl`, `otel-json` или `api-gateway`. Команда поддерживает text/json/SARIF output, `--baseline`, expiring `--suppressions`, атомарную запись через существующий механизм `--output` и CI-порог `--fail-on`. -Адаптеры принимают только metadata-поля. OpenTelemetry spans преобразуются по атрибутам агента, инструмента, окружения, provider и HTTP-статуса. API Gateway записи поддерживают snake_case и camelCase идентификаторы, JSONL и JSON-массив. Payload запроса и ответа не читается и не переносится в нормализованное событие. Provider matching теперь выполняется на уровне конкретного агента: фактический provider считается заявленным только тогда, когда он принадлежит модели, указанной в `agent.models`. +Адаптеры принимают только metadata-поля. OpenTelemetry spans преобразуются по атрибутам агента, инструмента, окружения, provider и HTTP-статуса. API Gateway записи поддерживают snake_case и camelCase идентификаторы, JSONL и JSON-массив. Payload запроса и ответа не читается и не переносится в нормализованное событие. Provider matching теперь выполняется на уровне конкретного агента: фактический provider считается заявленным только тогда, когда он принадлежит модели, указанной в `agent.models`. Normalized JSONL отклоняет sensitive keys (`prompt`, `arguments`, `request_body`, `response_body`, `headers`, `authorization`, `token` и аналогичные) и ограничивает длину metadata fields. Следующий этап — добавить адаптеры OpenTelemetry и API gateway, не меняя нормализованный контракт событий. diff --git a/internal/runtime/audit.go b/internal/runtime/audit.go index 9d7d1fe..dab44f2 100644 --- a/internal/runtime/audit.go +++ b/internal/runtime/audit.go @@ -10,14 +10,17 @@ import ( ) type Finding struct { - ID string `json:"id"` - RuleID string `json:"rule_id"` - Severity string `json:"severity"` - Message string `json:"message"` - AgentID string `json:"agent_id,omitempty"` - Confidence float64 `json:"confidence"` - Evidence []string `json:"evidence,omitempty"` - RemediationHint string `json:"remediation_hint"` + ID string `json:"id"` + RuleID string `json:"rule_id"` + Severity string `json:"severity"` + Message string `json:"message"` + AgentID string `json:"agent_id,omitempty"` + Confidence float64 `json:"confidence"` + Evidence []string `json:"evidence,omitempty"` + RemediationHint string `json:"remediation_hint"` + Suppressed bool `json:"suppressed,omitempty"` + SuppressionReason string `json:"suppression_reason,omitempty"` + SuppressionExpiresAt string `json:"suppression_expires_at,omitempty"` } type AuditReport struct { diff --git a/internal/runtime/ingest.go b/internal/runtime/ingest.go index d60c16a..a0eff36 100644 --- a/internal/runtime/ingest.go +++ b/internal/runtime/ingest.go @@ -37,6 +37,9 @@ func ReadEvents(r io.Reader, options Options) ([]Event, int, error) { if len(events) >= options.MaxEvents { return nil, skipped, fmt.Errorf("runtime event limit exceeded: %d", options.MaxEvents) } + if err := rejectSensitiveKeys(scanner.Bytes()); err != nil { + return nil, skipped, fmt.Errorf("validate runtime event %d: %w", len(events)+skipped+1, err) + } var event Event if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { return nil, skipped, fmt.Errorf("decode runtime event %d: %w", len(events)+skipped+1, err) @@ -68,5 +71,23 @@ func validateEvent(event Event) error { if strings.TrimSpace(event.Target) == "" { return fmt.Errorf("target is required") } + if len(event.RequestID) > 512 || len(event.AgentID) > 512 || len(event.AgentName) > 512 || len(event.Operation) > 256 || len(event.Target) > 1024 || len(event.Provider) > 256 || len(event.Action) > 128 || len(event.Environment) > 128 { + return fmt.Errorf("runtime metadata field exceeds its size limit") + } + return nil +} + +func rejectSensitiveKeys(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for key := range fields { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) + switch normalized { + case "prompt", "messages", "arguments", "toolarguments", "requestbody", "responsebody", "body", "headers", "authorization", "secret", "secrets", "apikey", "token": + return fmt.Errorf("sensitive field %q is not permitted", key) + } + } return nil } diff --git a/internal/runtime/priority2_test.go b/internal/runtime/priority2_test.go new file mode 100644 index 0000000..f5f7deb --- /dev/null +++ b/internal/runtime/priority2_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "strings" + "testing" + "time" +) + +func TestAuditSARIFIsDeterministic(t *testing.T) { + report := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "runtime_1", RuleID: "ACP-R001", Severity: "High", Message: "undeclared target", AgentID: "a1", Confidence: 0.9, RemediationHint: "declare target"}}} + first, err := report.SARIF() + if err != nil { + t.Fatalf("SARIF() error = %v", err) + } + second, err := report.SARIF() + if err != nil { + t.Fatalf("SARIF() error = %v", err) + } + if string(first) != string(second) || !strings.Contains(string(first), "ACP-R001") || !strings.Contains(string(first), "error") { + t.Fatalf("unexpected SARIF output: %s", first) + } +} + +func TestRuntimeBaselineAndSuppression(t *testing.T) { + report := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "runtime_keep", RuleID: "ACP-R001", Severity: "High", Message: "keep"}, {ID: "runtime_old", RuleID: "ACP-R002", Severity: "High", Message: "old"}}} + baseline := `{"schema_version":"runtime-audit.v1","findings":[{"id":"runtime_old"}]}` + if err := ApplyBaseline(&report, strings.NewReader(baseline)); err != nil { + t.Fatalf("ApplyBaseline() error = %v", err) + } + if len(report.Findings) != 1 || report.Findings[0].ID != "runtime_keep" { + t.Fatalf("unexpected baseline result: %+v", report.Findings) + } + suppressions := `{"suppressions":[{"finding_id":"runtime_keep","reason":"accepted risk","expires_at":"2099-01-01T00:00:00Z"}]}` + if err := ApplySuppressions(&report, strings.NewReader(suppressions), time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)); err != nil { + t.Fatalf("ApplySuppressions() error = %v", err) + } + if !report.Findings[0].Suppressed || report.Findings[0].SuppressionReason != "accepted risk" { + t.Fatalf("unexpected suppression result: %+v", report.Findings[0]) + } +} + +func TestReadEventsRejectsSensitiveMetadataKeys(t *testing.T) { + input := `{"timestamp":"2026-01-02T03:04:05Z","agent_id":"a1","operation":"tool_call","target":"crm.search","success":true,"arguments":{"customer_id":"123"}}` + if _, _, err := ReadEvents(strings.NewReader(input), Options{}); err == nil { + t.Fatal("ReadEvents() accepted sensitive arguments field") + } +} + +func TestReadEventsRejectsOversizedMetadata(t *testing.T) { + input := `{"timestamp":"2026-01-02T03:04:05Z","agent_id":"a1","operation":"tool_call","target":"crm.search","provider":"` + strings.Repeat("x", 257) + `","success":true}` + if _, _, err := ReadEvents(strings.NewReader(input), Options{}); err == nil { + t.Fatal("ReadEvents() accepted oversized provider") + } +} diff --git a/internal/runtime/sarif.go b/internal/runtime/sarif.go new file mode 100644 index 0000000..279ec77 --- /dev/null +++ b/internal/runtime/sarif.go @@ -0,0 +1,86 @@ +package runtime + +import ( + "encoding/json" + "sort" +) + +const sarifSchema = "https://json.schemastore.org/sarif-2.1.0.json" + +type sarifLog struct { + Schema string `json:"$schema"` + Version string `json:"version"` + Runs []sarifRun `json:"runs"` +} + +type sarifRun struct { + Tool sarifTool `json:"tool"` + Results []sarifResult `json:"results"` +} + +type sarifTool struct { + Driver sarifDriver `json:"driver"` +} +type sarifDriver struct { + Name string `json:"name"` + Version string `json:"version"` + InformationURI string `json:"informationUri"` + Rules []sarifRule `json:"rules,omitempty"` +} +type sarifRule struct { + ID string `json:"id"` + Name string `json:"name"` + ShortDescription sarifMessage `json:"shortDescription"` +} +type sarifResult struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message sarifMessage `json:"message"` + Properties map[string]any `json:"properties,omitempty"` + Suppressions []sarifSuppression `json:"suppressions,omitempty"` +} +type sarifSuppression struct { + Kind string `json:"kind"` + Justification string `json:"justification,omitempty"` +} +type sarifMessage struct { + Text string `json:"text"` +} + +// SARIF returns a deterministic SARIF 2.1.0 report for runtime findings. +func (r AuditReport) SARIF() ([]byte, error) { + ruleNames := make(map[string]string) + for _, finding := range r.Findings { + if _, exists := ruleNames[finding.RuleID]; !exists { + ruleNames[finding.RuleID] = finding.Message + } + } + rules := make([]sarifRule, 0, len(ruleNames)) + for id, description := range ruleNames { + rules = append(rules, sarifRule{ID: id, Name: id, ShortDescription: sarifMessage{Text: description}}) + } + sort.Slice(rules, func(i, j int) bool { return rules[i].ID < rules[j].ID }) + results := make([]sarifResult, 0, len(r.Findings)) + for _, finding := range r.Findings { + properties := map[string]any{"agent_id": finding.AgentID, "confidence": finding.Confidence, "remediation_hint": finding.RemediationHint} + result := sarifResult{RuleID: finding.RuleID, Level: sarifLevel(finding.Severity), Message: sarifMessage{Text: finding.Message}, Properties: properties} + if finding.Suppressed { + result.Suppressions = []sarifSuppression{{Kind: "external", Justification: finding.SuppressionReason}} + properties["suppression_expires_at"] = finding.SuppressionExpiresAt + } + results = append(results, result) + } + payload := sarifLog{Schema: sarifSchema, Version: "2.1.0", Runs: []sarifRun{{Tool: sarifTool{Driver: sarifDriver{Name: "Agent Control Plane Runtime Audit", Version: "runtime-audit.v1", InformationURI: "https://github.com/HlinorAI/agent-control-plane", Rules: rules}}, Results: results}}} + return json.MarshalIndent(payload, "", " ") +} + +func sarifLevel(severity string) string { + switch severity { + case "Critical", "High": + return "error" + case "Medium": + return "warning" + default: + return "note" + } +} diff --git a/internal/runtime/suppressions.go b/internal/runtime/suppressions.go new file mode 100644 index 0000000..9dbbff0 --- /dev/null +++ b/internal/runtime/suppressions.go @@ -0,0 +1,96 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +type suppressionEntry struct { + FindingID string `json:"finding_id"` + Reason string `json:"reason"` + ExpiresAt string `json:"expires_at"` +} +type suppressionDocument struct { + Suppressions *[]suppressionEntry `json:"suppressions"` +} + +func ApplyBaseline(report *AuditReport, baseline io.Reader) error { + if report == nil { + return fmt.Errorf("baseline requires a runtime audit report") + } + var previous AuditReport + decoder := json.NewDecoder(baseline) + if err := decoder.Decode(&previous); err != nil { + return fmt.Errorf("decode runtime baseline: %w", err) + } + if previous.SchemaVersion == "" { + return fmt.Errorf("baseline is not a runtime audit report") + } + known := make(map[string]bool, len(previous.Findings)) + for _, finding := range previous.Findings { + if finding.ID != "" { + known[finding.ID] = true + } + } + filtered := report.Findings[:0] + for _, finding := range report.Findings { + if !known[finding.ID] { + filtered = append(filtered, finding) + } + } + report.Findings = filtered + return nil +} + +func ApplySuppressions(report *AuditReport, source io.Reader, now time.Time) error { + if report == nil { + return fmt.Errorf("suppressions require a runtime audit report") + } + var document suppressionDocument + decoder := json.NewDecoder(source) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("decode runtime suppressions: %w", err) + } + if document.Suppressions == nil || len(*document.Suppressions) == 0 { + return fmt.Errorf("runtime suppression document must contain at least one suppression") + } + known := make(map[string]bool, len(report.Findings)) + for _, finding := range report.Findings { + known[finding.ID] = true + } + active := make(map[string]suppressionEntry, len(*document.Suppressions)) + for index, entry := range *document.Suppressions { + entry.FindingID = strings.TrimSpace(entry.FindingID) + entry.Reason = strings.TrimSpace(entry.Reason) + if entry.FindingID == "" || entry.Reason == "" { + return fmt.Errorf("runtime suppression %d requires finding_id and reason", index+1) + } + if len(entry.Reason) > 500 { + return fmt.Errorf("runtime suppression %q reason exceeds 500 characters", entry.FindingID) + } + expires, err := time.Parse(time.RFC3339, strings.TrimSpace(entry.ExpiresAt)) + if err != nil || !expires.After(now) { + return fmt.Errorf("runtime suppression %q has invalid or expired expires_at", entry.FindingID) + } + if !known[entry.FindingID] { + return fmt.Errorf("runtime suppression %q does not match a finding", entry.FindingID) + } + if _, exists := active[entry.FindingID]; exists { + return fmt.Errorf("duplicate runtime suppression for %q", entry.FindingID) + } + entry.ExpiresAt = expires.UTC().Format(time.RFC3339) + active[entry.FindingID] = entry + } + for index := range report.Findings { + if entry, ok := active[report.Findings[index].ID]; ok { + report.Findings[index].Suppressed = true + report.Findings[index].SuppressionReason = entry.Reason + report.Findings[index].SuppressionExpiresAt = entry.ExpiresAt + } + } + return nil +}