AI-powered security log analysis and SOC 2 compliance automation for ACME's multi-tenant platform.
secops-agent/
├── cmd/agent/main.go Entry point — analyze | serve subcommands
├── internal/
│ ├── config/config.go Environment-based configuration
│ ├── ingestion/
│ │ ├── reader.go JSON log file parser
│ │ └── classifier.go TENANT vs PLATFORM scope classification
│ ├── sanitizer/
│ │ ├── sanitizer.go Sanitizer interface + DefaultSanitizer
│ │ ├── pii.go IP / email / token / DB name redaction
│ │ └── injection.go Prompt-injection pattern detection
│ ├── llm/
│ │ ├── client.go LLM provider interface (swap without changing pipeline)
│ │ ├── anthropic.go Anthropic Claude implementation (retries, backoff)
│ │ └── prompt.go System prompt + user message builder
│ ├── analyzer/
│ │ └── analyzer.go Central pipeline coordinator
│ ├── validator/
│ │ └── validator.go LLM output schema / CVE / confidence validation
│ ├── reporter/
│ │ ├── json_reporter.go Structured JSON incident report + per-tenant output
│ │ └── markdown_reporter.go Human-readable Markdown summary + per-tenant output
│ ├── remediation/
│ │ ├── action.go ActionType, RiskLevel, ActionSpec, ActionOutcome types
│ │ ├── adapter.go Port interfaces: RateLimiter, IPBlocker, AccountBanner,
│ │ │ TenantIsolator, Notifier + AdapterSet bundle
│ │ ├── dispatcher.go Pure function: (severity, pattern, scope) → []ActionSpec
│ │ ├── engine.go Engine orchestrator: dedup, dry-run, human gate, execution
│ │ ├── audit.go RemediationAuditLogger — append-only JSON Lines
│ │ ├── approval.go PendingApprovalWriter — human-gate queue
│ │ └── adapters/
│ │ ├── noop.go NoOp stubs (log intent, no real API calls)
│ │ └── http.go HTTP stub templates (TODO comments, panic until implemented)
│ └── server/
│ ├── server.go HTTP report server entry point
│ ├── auth.go JWT HS256 parsing + RBAC middleware
│ ├── handlers.go Report query handlers with role-based filtering
│ ├── context.go Request-scoped claims context
│ └── audit.go Append-only access audit log (JSON Lines)
└── pkg/models/
├── log.go RawLog / ClassifiedLog types
├── sanitized.go SanitizedLog / SanitizedBatch types
└── incident.go LLMFinding / IncidentReport types
JSON Log File
│
▼ ingestion.Reader
[RawLog × N]
│
▼ ingestion.Classifier (logName → TENANT | PLATFORM scope)
[ClassifiedLog × N]
│
▼ sanitizer.DefaultSanitizer
• PII redaction (IP → REDACTED_IP, email → REDACTED_EMAIL, etc.)
• Prompt-injection detection on free-text fields
• tenant_id → TENANT_ID_N deterministic mapping (in-memory only)
[SanitizedLog × N]
│
▼ GroupIntoBatches() (one LLM call per tenant, never mixed)
[SanitizedBatch per tenant]
│
▼ llm.AnthropicClient.Analyze()
• System prompt: all instructions — no instructions in user role
• User message: <log_data>…</log_data> XML delimiter
[raw LLM JSON]
│
▼ validator.Validator
• JSON schema check + batch completeness
• Severity enum validation
• CVE format validation (strips malformed CVE identifiers)
• Confidence score threshold check
[AnalyzedBatch × N]
│
▼ reporter
• Global: incident_report.json + incident_summary.md
• Per-tenant: output/tenants/<tenant_id>/...
• Platform: output/platform/...
[IncidentReport]
│
▼ remediation.Engine (if SECOPS_REMEDIATION_ENABLED=true)
• Dispatches actions by (severity, attack_pattern, scope)
• Low-risk: auto-execute via adapters (NoOp by default)
• High-risk: write to pending_approvals.json + notify IM
• Every decision written to output/remediation_audit.log
- Go 1.21+
- An Anthropic API key (or adapt
AnthropicClientto use another provider)
cd secops-agent
go build ./... # verify it compilesRun the LLM analysis pipeline and generate reports locally:
export SECOPS_LLM_API_KEY="your-anthropic-api-key"
export SECOPS_LOG_FILE="../Attachment_sample-logs-50.json"
export SECOPS_LLM_MODEL="claude-sonnet-4-6"
# Optional overrides:
# export SECOPS_OUTPUT_DIR="./output"
# export SECOPS_CONFIDENCE_THRESHOLD="0.7"
# export SECOPS_INCLUDE_INFO="true"
go run ./cmd/agent/ analyze
# or (default mode):
go run ./cmd/agent/Output directory structure:
output/
├── incident_report.json ← Global report (all tenants, for secops-admin)
├── incident_summary.md ← Global human-readable summary
├── tenants/
│ ├── 600647/
│ │ ├── incident_report.json ← Only this tenant's findings
│ │ └── incident_summary.md
│ ├── 701234/
│ │ └── ...
│ └── ...
├── platform/
│ ├── incident_report.json ← PLATFORM-scoped findings only
│ └── incident_summary.md
├── remediation_audit.log ← Every remediation decision (JSON Lines)
└── pending_approvals.json ← High-risk actions awaiting human sign-off
Quick local checks after analyze:
# 1) Ensure global report exists
ls output/incident_report.json output/incident_summary.md
# 2) Ensure per-tenant and platform outputs exist
ls output/tenants
ls output/platform
# 3) Optional: inspect summary counts
rg "\"critical_count\"|\"high_count\"|\"medium_count\"|\"low_count\"|\"info_count\"" output/incident_report.jsonStart an HTTP report server with JWT-based RBAC access control (DESIGN.md Q3):
export SECOPS_JWT_SECRET="your-secret-key"
export SECOPS_OUTPUT_DIR="./output"
# export SECOPS_SERVER_PORT="8080" # default
go run ./cmd/agent/ serveThe serve API reads from SECOPS_OUTPUT_DIR, so run analysis once before starting the server:
export SECOPS_LLM_API_KEY="your-anthropic-api-key"
export SECOPS_LOG_FILE="../Attachment_sample-logs-50.json"
go run ./cmd/agent/ analyzeexport SECOPS_JWT_SECRET="dev-secret-change-me"
export SECOPS_OUTPUT_DIR="./output"
export SECOPS_SERVER_PORT="8080"
go run ./cmd/agent/ serveServer base URL: http://localhost:8080
| Endpoint | Auth | Description |
|---|---|---|
GET /healthz |
None | Health check |
GET /api/v1/reports |
JWT | List all reports (filtered by role) |
GET /api/v1/reports/{tenant_id} |
JWT | Get a specific tenant's report |
| Role | Access Scope |
|---|---|
secops-admin |
All reports (tenant + platform) |
tenant-admin |
Own tenant only (by tenant_id claim) |
compliance-auditor |
All reports, PII double-redacted |
engineer-readonly |
Platform + de-identified tenant reports |
JWT must use HS256 signing with SECOPS_JWT_SECRET. Claims:
{
"sub": "[email protected]",
"role": "tenant-admin",
"tenant_id": "600647",
"exp": 1711540800
}curl http://localhost:8080/healthzExpected: HTTP 200 with health status.
Use jwt.io or any HS256 tool with SECOPS_JWT_SECRET.
Recommended payloads for local testing:
secops-admin:{"sub":"[email protected]","role":"secops-admin","exp":9999999999}tenant-adminfor tenant600647:{"sub":"[email protected]","role":"tenant-admin","tenant_id":"600647","exp":9999999999}engineer-readonly:{"sub":"[email protected]","role":"engineer-readonly","exp":9999999999}
# Replace with real signed tokens
SECOPS_ADMIN_TOKEN="<SECOPS_ADMIN_JWT>"
TENANT_600647_TOKEN="<TENANT_600647_JWT>"
ENGINEER_TOKEN="<ENGINEER_READONLY_JWT>"List reports:
curl -H "Authorization: Bearer $SECOPS_ADMIN_TOKEN" \
http://localhost:8080/api/v1/reportsTenant-admin accesses own tenant (should pass):
curl -H "Authorization: Bearer $TENANT_600647_TOKEN" \
http://localhost:8080/api/v1/reports/600647Tenant-admin accesses another tenant (should be denied):
curl -i -H "Authorization: Bearer $TENANT_600647_TOKEN" \
http://localhost:8080/api/v1/reports/701234Engineer-readonly checks platform report visibility:
curl -H "Authorization: Bearer $ENGINEER_TOKEN" \
http://localhost:8080/api/v1/reportsMissing/invalid token check (should be 401):
curl -i http://localhost:8080/api/v1/reportsAfter calling the API, confirm audit entries are appended:
rg "\"path\":\"/api/v1/reports" output/audit.logYou should see one JSON line per request with role, subject, tenant_id (if present), path, method, and status.
Audit log format (JSON Lines, append-only):
{"ts":"2026-03-27T12:00:00Z","role":"tenant-admin","sub":"[email protected]","tenant_id":"600647","path":"/api/v1/reports/600647","method":"GET","status":200}The remediation engine is disabled by default and runs after report generation. It maps findings to enforcement actions and writes a full audit trail.
# Minimum: enable the engine (dry-run mode by default — no real API calls)
export SECOPS_REMEDIATION_ENABLED=true
# To actually execute actions, disable dry-run:
export SECOPS_REMEDIATION_DRY_RUN=false
# Optional tuning:
export SECOPS_REMEDIATION_MIN_SEVERITY=HIGH # skip MEDIUM/LOW/INFO
export SECOPS_REMEDIATION_BLOCK_IP_RISK=low # "low"=auto, "high"=human-gate
# IM notifications (Feishu / Slack) — leave empty to use NoOp logger:
export SECOPS_NOTIFIER_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/<token>"
export SECOPS_NOTIFIER_APPROVAL_WEBHOOK_URL="" # falls back to above if unset| Condition | Actions |
|---|---|
| PLATFORM scope (any severity) | notify only |
| CRITICAL + BruteForce / CredentialStuffing | notify, rate_limit, block_ip |
| CRITICAL + PromptInjection | notify, rate_limit |
| CRITICAL + UnauthorizedExec / PrivilegeEscalation / IAMPolicyViolation | notify, isolate_tenant (human gate) |
| CRITICAL + other patterns | notify |
| HIGH + any | notify, rate_limit |
| MEDIUM / LOW / INFO | No action |
SECOPS_REMEDIATION_ENABLED |
SECOPS_REMEDIATION_DRY_RUN |
Behaviour |
|---|---|---|
false (default) |
— | Engine skipped entirely |
true |
true (default) |
Dispatch runs, audit log written, no real API calls |
true |
false |
Full execution — adapters called, approvals queued |
Use ENABLED=true, DRY_RUN=true first to validate dispatch logic in production before enabling live execution.
When internal API contracts are known, update runRemediation() in cmd/agent/main.go:
// Before (NoOp):
adapterSet.RateLimiter = adapters.NewNoOpRateLimiter(logger)
// After (HTTP):
adapterSet.RateLimiter = adapters.NewHTTPRateLimiter(baseURL, token)No changes to engine, dispatcher, or audit logic required.
request_id is the end-to-end key:
# All findings
jq '[.tenant_reports[].findings[].request_id]' output/incident_report.json
# Auto-executed actions
grep '"executed":true' output/remediation_audit.log | jq -r '.request_id'
# Pending human approval
jq -r '.request_id' output/pending_approvals.jsonFindings whose request_id does not appear in either file had no action taken (severity below threshold or attack pattern with no mapped action).
# See all decisions after a run
cat output/remediation_audit.log | jq .
# Filter by action type
grep '"action_type":"rate_limit"' output/remediation_audit.log | jq .
# Check pending approvals
cat output/pending_approvals.json | jq .Full design rationale is in DESIGN.md:
- Q1: Multi-tenant data isolation strategy
- Q2: Prompt injection threat model for the LLM pipeline
- Q3: Access control model for incident reports
- Q4: Automated remediation engine (Port-and-Adapter pattern, risk model, IM integration, traceability)
Implemented security properties:
| Property | Implementation |
|---|---|
| PII never reaches LLM | sanitizer.DefaultSanitizer runs before any LLM call |
| Tenant data isolation | GroupIntoBatches() — one LLM call per tenant, never mixed |
| Per-tenant output storage | WritePerTenantJSON/MD() — each tenant gets its own directory |
| Injection neutralised | injection.go replaces adversarial fields with [FLAGGED_INJECTION_ATTEMPT] |
| LLM output untrusted | validator.go validates schema, severity enum, CVE format, confidence score |
| Tenant mapping ephemeral | tenantMapper is in-memory only, destroyed after the run |
| Report RBAC enforcement | server/auth.go JWT middleware + handlers.go role-based filtering |
| Access audit trail | server/audit.go append-only JSON Lines log (SOC 2 CC7.2/CC7.3) |
| Remediation isolated from analysis | Engine runs after reports are written; failure cannot corrupt reports |
| Remediation default-safe | ENABLED=false + DRY_RUN=true by default; no real API calls until explicitly enabled |
| High-risk actions human-gated | isolate_tenant, ban_account written to pending_approvals.json, never auto-executed |
| Remediation audit trail | remediation_audit.log records every action decision (executed/dry-run/skipped/pending) |
The following areas are not yet implemented. Each has a clear integration point in the current architecture.
All four enforcement adapters (RateLimiter, IPBlocker, AccountBanner, TenantIsolator) ship as NoOp stubs. Each stub in internal/remediation/adapters/http.go has a // TODO: comment describing the expected request shape. Once the internal API contract is known:
- Implement the corresponding
HTTP*struct method inhttp.go - Swap the NoOp for the HTTP implementation in
runRemediation()insidecmd/agent/main.go
No other files need to change.
| Adapter | What's needed |
|---|---|
HTTPRateLimiter |
Internal rate-limit API endpoint, request body shape, auth token |
HTTPIPBlocker |
Firewall/WAF API endpoint + source IP field (see item 5 below) |
HTTPAccountBanner |
Account management API endpoint + user identifier field |
HTTPTenantIsolator |
Tenant management API endpoint + isolation level parameter |
HTTPNotifier in adapters/http.go is a stub with // TODO: comments showing the expected Feishu and Slack webhook message shapes. Implementation requires:
- Feishu card message template for security alerts (
approval_required=false) - Feishu interactive card with Approve/Reject buttons for approval requests (
approval_required=true) - Equivalent Slack Block Kit templates
- Set
SECOPS_NOTIFIER_WEBHOOK_URL(and optionallySECOPS_NOTIFIER_APPROVAL_WEBHOOK_URL) to activate
Currently, high-risk actions are written to pending_approvals.json and an IM notification is sent, but clicking Approve in the IM does nothing yet. Closing this loop requires:
- A
POST /remediation/approve?key=<dedupe_key>endpoint inservemode - A
POST /remediation/reject?key=<dedupe_key>endpoint - On approval: look up the
dedupe_keyinpending_approvals.json, call the appropriate adapter, update status to"approved" - The existing JWT middleware in
internal/server/auth.gocan be reused; onlysecops-adminshould be permitted to approve
The dedupe_key field already exists in every pending_approvals.json entry as the lookup key.
A minimal frontend on top of the existing serve mode could expose:
| Page | Data source |
|---|---|
| Pending approvals queue | New GET /remediation/pending endpoint |
| Remediation history | New GET /remediation/history endpoint (reads remediation_audit.log) |
| Finding detail (click through from request_id) | Existing GET /api/v1/reports/{tenant_id} |
RBAC is already in place — Approve actions would be restricted to secops-admin.
LLMFinding does not currently carry a source IP (PII is stripped before the LLM call). The block_ip action therefore records tenant_id and request_id but cannot pass an IP to the firewall API. Two options:
- Option A: Add a
RequestIDToIP map[string]stringtoAnalyzedBatch, populated fromRawLog.HttpRequest.RemoteIPafter sanitization — passed to the Remediation Engine without ever entering the LLM context. - Option B: Add a
SourceIPfield toLLMFindingand populate it in a post-sanitization enrichment step.
The current dedup map is in-memory and resets on every pipeline run. If the same log file is re-analyzed, remediation actions will fire again. Fix: at engine startup, load all dedupe_key values from remediation_audit.log into the seen map before processing. The file is JSON Lines, so loading is O(n) with minimal overhead.
CVE identifiers are currently validated for format (CVE-YYYY-NNNN...) and stripped if malformed. A future extension could cross-check them against the NVD API or an internal CVE feed to confirm existence before including them in reports.
The llm.Client interface makes swapping providers straightforward:
- Create
internal/llm/openai.go(orvertex.go, etc.) - Implement
llm.Client:type OpenAIClient struct { ... } func (c *OpenAIClient) Analyze(ctx context.Context, batch models.SanitizedBatch) ([]byte, error) { ... }
- Add a
case "openai":branch incmd/agent/main.go
No other changes required — the interface boundary ensures the rest of the pipeline is unaffected.
- CVE handling currently validates identifier format (
CVE-YYYY-NNNN...) and strips malformed entries. Existence checks against external CVE databases are an optional future extension (see What's Next above).