diff --git a/README.md b/README.md index d61bc60..25f6d13 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 stable report formats for static and runtime findings; CSV and issue export are planned. +- JSON and SARIF are stable report formats for static and runtime findings. Runtime snapshots can be compared with `agentctl runtime-diff` and exported as CSV or HTML; issue export is 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 23320b9..aac8f05 100644 --- a/cmd/agentctl/main.go +++ b/cmd/agentctl/main.go @@ -24,7 +24,8 @@ Usage: agentctl version agentctl init agentctl scan [flags] - agentctl runtime-audit [flags] + agentctl runtime-audit [flags] + agentctl runtime-diff [flags] Scan flags: --baseline file suppress findings already present in a JSON report @@ -45,6 +46,10 @@ Runtime audit flags: --output file write the audit report to a file instead of stdout --suppressions file suppress active runtime findings with reason and expiry +Runtime diff flags: + --format format output format: text, json, csv or html + --output file write the diff to a file instead of stdout + The scanner is read-only and metadata-only. It does not execute scanned content. ` @@ -79,6 +84,9 @@ func run(args []string, stdout, stderr io.Writer) error { if args[0] == "runtime-audit" { return runRuntimeAudit(args[1:], stdout, stderr) } + if args[0] == "runtime-diff" { + return runRuntimeDiff(args[1:], stdout, stderr) + } if args[0] != "scan" { return fmt.Errorf("unknown command %q; available commands are init, scan and runtime-audit", args[0]) } diff --git a/cmd/agentctl/runtime_diff.go b/cmd/agentctl/runtime_diff.go new file mode 100644 index 0000000..fbf2189 --- /dev/null +++ b/cmd/agentctl/runtime_diff.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + + "github.com/HlinorAI/agent-control-plane/internal/runtime" +) + +func runRuntimeDiff(args []string, stdout, stderr io.Writer) error { + if len(args) < 2 { + return errors.New("runtime-diff requires before and after runtime JSON reports") + } + fs := flag.NewFlagSet("runtime-diff", flag.ContinueOnError) + fs.SetOutput(stderr) + format := fs.String("format", "text", "output format: text, json, csv or html") + output := fs.String("output", "", "write the diff to a file instead of stdout") + if err := fs.Parse(args[2:]); err != nil { + return err + } + if *format != "text" && *format != "json" && *format != "csv" && *format != "html" { + return fmt.Errorf("unsupported runtime diff format %q", *format) + } + before, err := runtime.ReadAuditReport(args[0]) + if err != nil { + return err + } + after, err := runtime.ReadAuditReport(args[1]) + if err != nil { + return err + } + diff := runtime.DiffReports(before, after) + var payload []byte + switch *format { + case "json": + payload, err = json.MarshalIndent(diff, "", " ") + if err == nil { + payload = append(payload, '\n') + } + case "csv": + payload, err = diff.CSV() + case "html": + payload, err = diff.HTML() + default: + payload = []byte(fmt.Sprintf("Runtime diff\nAdded findings: %d\nRemoved findings: %d\nUnchanged findings: %d\n", len(diff.Added), len(diff.Removed), diff.Unchanged)) + } + if err != nil { + return err + } + if *output != "" { + return writeOutputFile(*output, payload) + } + _, err = stdout.Write(payload) + return err +} diff --git a/docs/runtime-audit-mvp.md b/docs/runtime-audit-mvp.md index 2e69c04..acd5fd0 100644 --- a/docs/runtime-audit-mvp.md +++ b/docs/runtime-audit-mvp.md @@ -92,6 +92,8 @@ Runtime-аудит должен быть **наблюдателем, а не и Добавлена команда `agentctl runtime-audit --inventory `. Флаг `--source` выбирает `jsonl`, `otel-json` или `api-gateway`. Команда поддерживает text/json/SARIF output, `--baseline`, expiring `--suppressions`, атомарную запись через существующий механизм `--output` и CI-порог `--fail-on`. +Для сравнения snapshots используется `agentctl runtime-diff before.json after.json`. Команда показывает добавленные, удалённые и неизменившиеся findings и поддерживает `--format text|json|csv|html`. Diff сравнивает стабильные finding IDs, поэтому его можно использовать в nightly security review и регрессионных CI-проверках. + Адаптеры принимают только 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/reporting.go b/internal/runtime/reporting.go new file mode 100644 index 0000000..1a768c0 --- /dev/null +++ b/internal/runtime/reporting.go @@ -0,0 +1,97 @@ +package runtime + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "html/template" + "os" + "sort" +) + +type Diff struct { + SchemaVersion string `json:"schema_version"` + Added []Finding `json:"added_findings"` + Removed []Finding `json:"removed_findings"` + Unchanged int `json:"unchanged_findings"` +} + +func DiffReports(before, after AuditReport) Diff { + old := make(map[string]Finding, len(before.Findings)) + for _, f := range before.Findings { + old[f.ID] = f + } + current := make(map[string]Finding, len(after.Findings)) + for _, f := range after.Findings { + current[f.ID] = f + } + result := Diff{SchemaVersion: "runtime-diff.v1"} + for id, finding := range current { + if _, ok := old[id]; ok { + result.Unchanged++ + } else { + result.Added = append(result.Added, finding) + } + } + for id, finding := range old { + if _, ok := current[id]; !ok { + result.Removed = append(result.Removed, finding) + } + } + sort.Slice(result.Added, func(i, j int) bool { return result.Added[i].ID < result.Added[j].ID }) + sort.Slice(result.Removed, func(i, j int) bool { return result.Removed[i].ID < result.Removed[j].ID }) + return result +} + +func (d Diff) CSV() ([]byte, error) { + var buffer bytes.Buffer + writer := csv.NewWriter(&buffer) + if err := writer.Write([]string{"change", "finding_id", "rule_id", "severity", "agent_id", "message"}); err != nil { + return nil, err + } + write := func(change string, findings []Finding) error { + for _, f := range findings { + if err := writer.Write([]string{change, f.ID, f.RuleID, f.Severity, f.AgentID, f.Message}); err != nil { + return err + } + } + return nil + } + if err := write("added", d.Added); err != nil { + return nil, err + } + if err := write("removed", d.Removed); err != nil { + return nil, err + } + writer.Flush() + return buffer.Bytes(), writer.Error() +} + +func (d Diff) HTML() ([]byte, error) { + const page = `Agent Control Plane Runtime Diff

Runtime audit diff

Added: {{len .Added}} · Removed: {{len .Removed}} · Unchanged: {{.Unchanged}}

{{range .Added}}{{end}}{{range .Removed}}{{end}}
ChangeIDRuleSeverityAgentMessage
Added{{.ID}}{{.RuleID}}{{.Severity}}{{.AgentID}}{{.Message}}
Removed{{.ID}}{{.RuleID}}{{.Severity}}{{.AgentID}}{{.Message}}
` + t, err := template.New("diff").Parse(page) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + if err := t.Execute(&buffer, d); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func ReadAuditReport(path string) (AuditReport, error) { + data, err := os.ReadFile(path) + if err != nil { + return AuditReport{}, fmt.Errorf("read runtime report: %w", err) + } + var report AuditReport + if err := json.Unmarshal(data, &report); err != nil { + return AuditReport{}, fmt.Errorf("decode runtime report: %w", err) + } + if report.SchemaVersion == "" { + return AuditReport{}, fmt.Errorf("runtime report schema_version is required") + } + return report, nil +} diff --git a/internal/runtime/reporting_test.go b/internal/runtime/reporting_test.go new file mode 100644 index 0000000..eb15749 --- /dev/null +++ b/internal/runtime/reporting_test.go @@ -0,0 +1,29 @@ +package runtime + +import ( + "strings" + "testing" +) + +func TestDiffReportsIsDeterministic(t *testing.T) { + before := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "old", RuleID: "ACP-R001", Severity: "High", Message: "old"}, {ID: "same", RuleID: "ACP-R002", Severity: "Medium", Message: "same"}}} + after := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "new", RuleID: "ACP-R003", Severity: "Critical", Message: "new"}, {ID: "same", RuleID: "ACP-R002", Severity: "Medium", Message: "same"}}} + diff := DiffReports(before, after) + if len(diff.Added) != 1 || diff.Added[0].ID != "new" || len(diff.Removed) != 1 || diff.Removed[0].ID != "old" || diff.Unchanged != 1 { + t.Fatalf("unexpected diff: %+v", diff) + } + csv, err := diff.CSV() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(csv), "added,new") || !strings.Contains(string(csv), "removed,old") { + t.Fatalf("unexpected csv: %s", csv) + } + html, err := diff.HTML() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(html), "Runtime audit diff") { + t.Fatalf("unexpected html: %s", html) + } +}