Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion cmd/agentctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Usage:
agentctl version
agentctl init <path>
agentctl scan <path> [flags]
agentctl runtime-audit <events> [flags]
agentctl runtime-audit <events> [flags]
agentctl runtime-diff <before.json> <after.json> [flags]

Scan flags:
--baseline file suppress findings already present in a JSON report
Expand All @@ -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.
`

Expand Down Expand Up @@ -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])
}
Expand Down
58 changes: 58 additions & 0 deletions cmd/agentctl/runtime_diff.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions docs/runtime-audit-mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Runtime-аудит должен быть **наблюдателем, а не и

Добавлена команда `agentctl runtime-audit <events> --inventory <report.json>`. Флаг `--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, не меняя нормализованный контракт событий.
Expand Down
97 changes: 97 additions & 0 deletions internal/runtime/reporting.go
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><meta charset="utf-8"><title>Agent Control Plane Runtime Diff</title><style>body{font:15px system-ui;margin:2rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:.5rem;text-align:left}.added{color:#087f23}.removed{color:#b42318}</style><h1>Runtime audit diff</h1><p>Added: {{len .Added}} · Removed: {{len .Removed}} · Unchanged: {{.Unchanged}}</p><table><tr><th>Change</th><th>ID</th><th>Rule</th><th>Severity</th><th>Agent</th><th>Message</th></tr>{{range .Added}}<tr class="added"><td>Added</td><td>{{.ID}}</td><td>{{.RuleID}}</td><td>{{.Severity}}</td><td>{{.AgentID}}</td><td>{{.Message}}</td></tr>{{end}}{{range .Removed}}<tr class="removed"><td>Removed</td><td>{{.ID}}</td><td>{{.RuleID}}</td><td>{{.Severity}}</td><td>{{.AgentID}}</td><td>{{.Message}}</td></tr>{{end}}</table>`
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
}
29 changes: 29 additions & 0 deletions internal/runtime/reporting_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading