diff --git a/.env.example b/.env.example
index 6512976..8d44d9c 100644
--- a/.env.example
+++ b/.env.example
@@ -45,7 +45,8 @@ GITHUB_TOKEN=your_github_personal_access_token
# connectors for local development only, set this to 1 (logged as a warning).
# CONCORD_ALLOW_INSECURE_TRANSPORT=1
-# ── Connector tokens ──────────────────────────────────────────────TERRASECURE_TOKEN=
+# ── Connector tokens ──────────────────────────────────────────────
+TERRASECURE_TOKEN=
TRIVY_TOKEN=
KAGENT_TOKEN=
HOLMESGPT_TOKEN=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb04903..7bebfac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,14 +6,34 @@ on:
pull_request:
branches: [main, develop]
+# Least privilege: this workflow only needs to read the repo.
+permissions:
+ contents: read
+
+# Cancel superseded runs on the same ref to save minutes.
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- - run: pip install -r requirements/dev.txt
- - run: ruff check .
- - run: pytest tests/unit -v --tb=short
+ cache: pip
+ cache-dependency-path: requirements/dev.txt
+
+ - name: Install dependencies
+ run: pip install -r requirements/dev.txt "psycopg[binary,pool]"
+
+ - name: Lint
+ run: ruff check .
+
+ - name: Test (full suite, excluding slow)
+ env:
+ CONCORD_DB_PATH: ":memory:"
+ run: pytest tests/ -q -m "not slow"
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4e5c9ce..148d1e9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,10 +4,39 @@ on:
push:
tags: ["v*"]
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
release:
runs-on: ubuntu-latest
+ permissions:
+ contents: write # create the GitHub release
+ packages: write # push the image to GHCR
steps:
- uses: actions/checkout@v4
- - run: docker build -t ghcr.io/beyondbug/concord:${{ github.ref_name }} .
- - uses: softprops/action-gh-release@v2
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ ghcr.io/beyondbug/concord:${{ github.ref_name }}
+ ghcr.io/beyondbug/concord:latest
+
+ - name: Create GitHub release
+ uses: softprops/action-gh-release@v2
+ with:
+ generate_release_notes: true
\ No newline at end of file
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 5985c6b..b0451f0 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -5,25 +5,49 @@ on:
branches: [main, develop]
pull_request:
+# Default to read-only; the SARIF upload job elevates only what it needs.
permissions:
contents: read
- security-events: write
- actions: read
+
+concurrency:
+ group: security-${{ github.ref }}
+ cancel-in-progress: true
jobs:
trivy:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write # required to upload SARIF to code scanning
steps:
- uses: actions/checkout@v4
- - name: Run Trivy filesystem scan
- uses: aquasecurity/trivy-action@master
+
+ - name: Trivy filesystem scan
+ # Pinned to a released tag rather than @master for supply-chain safety.
+ uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy.sarif
+ severity: CRITICAL,HIGH
+
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy.sarif
continue-on-error: true
+
+ pip-audit:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Audit Python dependencies
+ run: |
+ pip install pip-audit
+ pip-audit -r requirements/base.txt
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ee4e2c2
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,52 @@
+# Changelog
+
+All notable changes to Concord are documented here. The format is based on
+[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
+semantic versioning once it reaches a tagged release.
+
+## [Unreleased]
+
+### Added
+- **SecurityPolicyAgent** with a dependency-free source-code pattern scanner
+ (Python/JS/TS/Go/PHP).
+- **Durable persistence**: SQLite by default, optional **PostgreSQL** backend
+ selected via `CONCORD_DATABASE_URL` with graceful fallback and schema
+ migration.
+- **API authentication** (API key, fail-safe dev mode) and request
+ **correlation IDs** threaded into logs and the audit trail.
+- **MCP transport hardening**: TLS verification by default, CA bundles, mTLS,
+ and refusal of plaintext URLs unless explicitly opted in.
+- **Prompt-injection sanitization** of untrusted tool output before it reaches
+ the LLM prompt.
+- **Redis-backed dedup** with an in-memory TTL fallback.
+- **Approval lifecycle**: approve, reject, and expire flows — all durable and
+ audited — plus a pending-approvals queue.
+- **Structured (JSON) logging** mode.
+- **Web dashboard** with seven live views: Overview, Findings, Incidents, Security,
+ Approvals, Audit, Settings (all read from the live API; no mock data).
+- **CLI** with human and `--json` output: `health`, `findings`, `audit`,
+ `approvals`, `approve`, `reject`, `stats`, `diagnostics`, `invoke`, `agents`,
+ and shell-completion help.
+- **Docker** multi-stage non-root image with a health check; hardened
+ `docker-compose` (loopback ports, read-only rootfs, required secrets,
+ service health checks).
+- **Helm chart** with security context, resource limits, probes, and a
+ least-privilege service account.
+- **Documentation**: rewritten `README.md`, `docs/ARCHITECTURE.md`, and
+ `docs/threat-model.md`.
+- **CI/CD hardening**: least-privilege workflow permissions, full-suite CI,
+ pinned actions, and a dependency-audit job.
+
+### Changed
+- Confidence scoring remains deterministic
+ (`severity_weight × source_reliability`), never LLM self-reported.
+- Audit records are now durable and correlation-tagged (previously log-only).
+
+### Security
+- Per-connector scoped credentials; no master credential.
+- Human approval gate for consequential/tie-break outcomes.
+
+### Known limitations
+- Kubernetes and Observability agents are scaffolded but require live MCP
+ services (kagent / HolmesGPT) to complete.
+- Terraform assets under `infra/` remain placeholders.
\ No newline at end of file
diff --git a/README.md b/README.md
index 0c0b86b..0363eb3 100644
--- a/README.md
+++ b/README.md
@@ -7,3 +7,277 @@
One AI brain across your entire DevSecOps stack.
+
+
+ A self-hosted platform that triages security findings, runs domain agents,
+ arbitrates their results with a deterministic confidence model, and keeps a
+ human in the loop for consequential actions — with a full audit trail.
+
+
+---
+
+## What Concord is
+
+Concord ingests security findings (from CI/CD, IaC scans, webhooks, or its own
+scanners), decides whether each one is trivial enough to fast-path or needs
+deeper analysis, runs the relevant **domain agents**, and **arbitrates** their
+competing conclusions using a deterministic confidence score. Low-confidence
+ties are escalated to a **human approval gate** rather than auto-resolved.
+Every decision — fast-path or AI-path, approval or rejection — is written to a
+durable, correlation-tagged **audit trail**.
+
+It is designed to orchestrate existing security tools over the Model Context
+Protocol (MCP), not replace them.
+
+### Why it exists
+
+Security teams drown in findings from a dozen disconnected tools, each with its
+own console and confidence heuristics. Concord gives them one triage brain: a
+consistent, auditable pipeline that decides what matters, explains why, and only
+interrupts a human when a decision genuinely needs one.
+
+---
+
+## Key principles (enforced in code and tests)
+
+- **Deterministic confidence** — `confidence = severity_weight × source_reliability`.
+ Never LLM self-reported. This is what arbitration ranks on.
+- **Everything is audited** — every finding, including fast-path, produces an
+ audit record. Human approvals and rejections are audited too.
+- **Scoped credentials** — per-connector tokens via the credential broker; no
+ master credential.
+- **Swappable LLM** — the provider is chosen by `LLM_PROVIDER`; provider details
+ never leak into the orchestration logic.
+- **Human-in-the-loop** — consequential/tie-break outcomes require explicit
+ human approval; nothing destructive happens silently.
+
+---
+
+## Architecture
+
+```mermaid
+flowchart TD
+ U[User / CI / Webhook] -->|finding| API[FastAPI API]
+ CLI[Concord CLI] --> API
+ DASH[Web Dashboard] --> API
+ API --> ORCH[Orchestrator]
+ ORCH --> TRIAGE{Triage gate}
+ TRIAGE -->|trivial| FAST[Fast path]
+ TRIAGE -->|needs analysis| AGENTS[Domain agents]
+ AGENTS --> INFRA[Infra]
+ AGENTS --> CICD[CI/CD]
+ AGENTS --> SEC[Security]
+ AGENTS -. planned .-> K8S[Kubernetes ·MCP]
+ AGENTS -. planned .-> OBS[Observability ·MCP]
+ INFRA & CICD & SEC --> ARB[Arbitration]
+ ARB -->|clear winner| RESOLVE[Auto-resolve]
+ ARB -->|close call| APPROVE[Human approval gate]
+ FAST & RESOLVE & APPROVE --> STORE[(Persistence·SQLite/Postgres)]
+ STORE --> AUDIT[(Audit trail)]
+ ORCH --> LLM[LLM provider ·swappable]
+```
+
+### Layers
+
+| Layer | Location | Responsibility |
+|-------|----------|----------------|
+| MCP runtime | `core/mcp_runtime/` | Transport (TLS/mTLS), registry, audit |
+| Orchestrator | `core/orchestrator/` | Triage → agents → arbitration → LLM → store |
+| Domain agents | `agents//` | Each wraps a backing scan/tool with a contract |
+| Connectors | `connectors/tools.yaml` | Declarative external tools, orchestrated not replaced |
+
+### Request/execution flow
+
+1. A finding arrives (API, webhook, CLI, or a scan).
+2. The **triage gate** applies rules (severity, known patterns, dedup). Trivial
+ findings take the **fast path** — no LLM call.
+3. Otherwise domain agents analyze it; each returns a structured result and a
+ deterministic confidence score.
+4. **Arbitration** ranks the agents. A clear winner auto-resolves; a close call
+ (small confidence gap) becomes a **pending approval**.
+5. The result and an **audit record** (tagged with the request correlation ID)
+ are persisted.
+6. A human approves or rejects pending items; stale ones can be expired.
+
+---
+
+## Features
+
+- **Triage + arbitration** with a deterministic confidence model.
+- **Domain agents**: Infra (Terraform/pattern scan), CI/CD (K8s/Dockerfile),
+ Security (source-code pattern scan). Kubernetes and Observability agents are
+ **scaffolded and planned** — they require live MCP services (kagent /
+ HolmesGPT) and are not yet wired end-to-end.
+- **Persistence**: durable SQLite by default; **PostgreSQL** backend selected
+ automatically when `CONCORD_DATABASE_URL` is set, with graceful fallback.
+- **Auditable approvals**: approve, reject, and expire flows — all durable and
+ audited; a pending-approvals queue.
+- **Security**: API-key auth (fail-safe dev mode), TLS/mTLS transport,
+ prompt-injection sanitization on untrusted tool output, dedup.
+- **Observability**: structured (JSON) logging and request **correlation IDs**
+ threaded from the API into logs and audit records.
+- **Interfaces**: a web dashboard (Overview / Findings / Approvals / Audit) and
+ a CLI with human and `--json` machine-readable output.
+
+---
+
+## Quickstart
+
+### Local (Python)
+
+```bash
+python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
+pip install -r requirements/dev.txt
+cp .env.example .env # then edit as needed
+
+uvicorn api.main:app --reload --port 8000
+# open http://localhost:8000 (dashboard)
+```
+
+Trigger a demo finding through the pipeline:
+
+```bash
+python concord_cli/main.py invoke -s CRITICAL
+python concord_cli/main.py findings --json
+python concord_cli/main.py audit
+```
+
+### Docker
+
+```bash
+# Set POSTGRES_PASSWORD in .env first (required; no weak default).
+docker compose up --build
+```
+
+The image is multi-stage, runs as a non-root user, and has a health check.
+
+---
+
+## Configuration
+
+All configuration is via environment variables (see `.env.example`). Highlights:
+
+| Variable | Purpose |
+|----------|---------|
+| `LLM_PROVIDER` | Which LLM backend to use (swappable). |
+| `CONCORD_API_KEY` | Enables API auth. Unset = open dev mode (logged). |
+| `CONCORD_DB_PATH` | SQLite path (default backend). |
+| `CONCORD_DATABASE_URL` / `POSTGRES_URL` | Use PostgreSQL; falls back to SQLite if unreachable. |
+| `REDIS_URL` | Enables Redis-backed dedup; falls back to in-memory. |
+| `CONCORD_LOG_FORMAT` | `json` for structured logs, else human text. |
+| `CONCORD_ALLOW_INSECURE_TRANSPORT` | Allow plaintext MCP URLs (dev only). |
+| `WEBHOOK_SECRET` | HMAC secret for the GitHub webhook. |
+
+> If `POSTGRES_URL` is set but no database is running, the app falls back to
+> SQLite **at startup** (one short timeout at boot). Comment it out to skip
+> Postgres entirely.
+
+---
+
+## CLI
+
+```
+concord health # API health + auth posture
+concord findings [--json] # recent findings + stats
+concord audit [--json] # audit trail with correlation IDs
+concord approvals # pending human decisions
+concord approve # resolve a tie-break
+concord reject # reject a pending finding
+concord invoke -s CRITICAL # run the pipeline on a demo finding
+concord agents # domain agents + status
+```
+
+`--json` (or `CONCORD_OUTPUT=json`) emits pure JSON for scripting; colour is
+disabled automatically when output is piped.
+
+---
+
+## API
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| GET | `/health` | Liveness + auth posture |
+| GET | `/findings/` | Findings + stats |
+| GET | `/findings/{id}` | One finding |
+| GET | `/audit/` | Audit trail |
+| GET | `/events/approvals/pending` | Pending approvals |
+| POST | `/events/findings/{id}/approve/{agent}` | Approve a tie-break |
+| POST | `/events/findings/{id}/reject` | Reject a finding |
+| POST | `/events/approvals/expire` | Expire stale approvals |
+| POST | `/events/demo` | Run the pipeline on a demo finding |
+| POST | `/events/scan-crms` | Trigger a repository scan |
+| POST | `/events/github` | GitHub webhook (HMAC-verified) |
+
+Data/state routes require the API key when `CONCORD_API_KEY` is set. `/health`,
+`/`, and the HMAC-verified webhook are public by design.
+
+---
+
+## Deployment (Kubernetes / Helm)
+
+```bash
+helm lint helm/concord
+helm template concord helm/concord | kubectl apply -f -
+```
+
+The chart ships hardened defaults: non-root pod/container security context,
+read-only root filesystem, dropped capabilities, resource requests/limits,
+liveness/readiness probes, and a least-privilege service account with the token
+not mounted. Provide secrets via a Kubernetes Secret referenced by
+`envFromSecret`.
+
+---
+
+## Testing
+
+```bash
+ruff check .
+pytest tests/ -q -m "not slow" # fast suite
+pytest -m slow # slow/real-timeout tests
+```
+
+The suite covers triage, arbitration, agents, persistence (SQLite + Postgres
+logic + migration), auth, transport security, sanitization, dedup, the approval
+lifecycle, observability/correlation, and the CLI.
+
+---
+
+## Project structure
+
+```
+api/ FastAPI app, routes, middleware, dashboard
+agents/ domain agents (infra, cicd, security, k8s*, observability*)
+core/
+ orchestrator/ triage → agents → arbitration → LLM → store
+ arbitration/ deterministic confidence + resolver
+ triage/ gate + rules (severity, patterns, dedup)
+ persistence/ SQLite + PostgreSQL stores
+ mcp_runtime/ transport (TLS/mTLS), registry, audit
+ observability/ correlation IDs + structured logging
+ credential_broker/ scoped per-connector tokens
+concord_cli/ Typer CLI
+connectors/ tools.yaml manifest
+helm/ infra/ deployment assets
+tests/ unit + integration
+docs/ architecture, completion tracker
+```
+`*` scaffolded / planned (requires live MCP services).
+
+---
+
+## Status
+
+Concord is under active development. The triage/arbitration core, persistence,
+approvals, security controls, observability, CLI, and dashboard read views are
+implemented and tested. The Kubernetes and Observability agents are scaffolded
+but require live MCP endpoints to complete. See `docs/PROJECT_COMPLETION.md` for
+a slice-by-slice status.
+
+## Contributing
+
+See `CONTRIBUTING.md` and `DEVELOPMENT.md`. Before changing core behaviour, read
+`CLAUDE.md` — it records the invariants above that must not be broken casually.
+
+## License
+
+See `LICENSE`.
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
index 51ee511..1dea709 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -14,4 +14,4 @@ See docs/threat-model.md for the full threat model.
- No master credential. Per-connector scoped tokens only.
- Rollback recommendations always require human approval.
- Every action (including fast-path) logged to audit trail.
-- Tool output sanitized before LLM context injection (Phase 3).
+- Tool output sanitized before LLM context injection.
\ No newline at end of file
diff --git a/api/main.py b/api/main.py
index dd02b5d..1d9bbc6 100644
--- a/api/main.py
+++ b/api/main.py
@@ -56,4 +56,4 @@ def health():
"service": "concord",
"version": "0.1.0",
"auth_enforced": auth_is_enforced(),
- }
\ No newline at end of file
+ }
diff --git a/api/middleware/logging.py b/api/middleware/logging.py
index a6a84d0..63d3eff 100644
--- a/api/middleware/logging.py
+++ b/api/middleware/logging.py
@@ -49,4 +49,4 @@ async def dispatch(self, request: Request, call_next):
response.status_code, duration_ms,
)
response.headers[_REQUEST_ID_HEADER] = request_id
- return response
\ No newline at end of file
+ return response
diff --git a/api/routes/agents.py b/api/routes/agents.py
new file mode 100644
index 0000000..fbed268
--- /dev/null
+++ b/api/routes/agents.py
@@ -0,0 +1,51 @@
+"""
+api/routes/agents.py
+Agent metadata endpoint — single source of truth for the UI/CLI.
+
+Status is derived honestly: an agent is "active" only if its analyze() is
+implemented (does not raise NotImplementedError). The Kubernetes and
+Observability agents are scaffolded and report "planned" until their MCP
+backends are wired in.
+"""
+from fastapi import APIRouter
+
+from core.models.agent_response import SOURCE_RELIABILITY
+
+router = APIRouter(prefix="/agents", tags=["agents"])
+
+# Human-facing backing description per domain. Kept here (not fabricated from
+# the model) so the label matches what actually runs.
+_BACKING = {
+ "infra": "TerraSecure pattern scanner",
+ "cicd": "Trivy · Checkov",
+ "security": "source-code pattern scan",
+ "kubernetes": "kagent (MCP)",
+ "observability": "HolmesGPT (MCP)",
+}
+
+# Active = analyze() implemented. Planned = scaffolded, needs a live MCP service.
+_ACTIVE = {"infra", "cicd", "security"}
+
+
+def _agent_list() -> list[dict]:
+ out = []
+ for domain, reliability in SOURCE_RELIABILITY.items():
+ out.append({
+ "domain": domain,
+ "reliability": reliability,
+ "backing": _BACKING.get(domain, domain),
+ "status": "active" if domain in _ACTIVE else "planned",
+ })
+ # Stable order: active first (by reliability desc), then planned.
+ out.sort(key=lambda a: (a["status"] != "active", -a["reliability"]))
+ return out
+
+
+@router.get("/")
+async def list_agents():
+ agents = _agent_list()
+ return {
+ "agents": agents,
+ "active": sum(1 for a in agents if a["status"] == "active"),
+ "planned": sum(1 for a in agents if a["status"] == "planned"),
+ }
\ No newline at end of file
diff --git a/api/routes/events.py b/api/routes/events.py
index 1018fb3..ac7d9e3 100644
--- a/api/routes/events.py
+++ b/api/routes/events.py
@@ -1,15 +1,17 @@
"""
api/routes/events.py
-Webhook receiver + /demo endpoint for Friday review.
+Webhook receiver + /demo endpoint + approval lifecycle endpoints.
"""
import hashlib
import hmac
import logging
import os
+from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from core.models.finding import Finding
+from core.persistence.store import AuditRecord, get_store
router = APIRouter(prefix="/events", tags=["events"])
logger = logging.getLogger("concord.events")
@@ -58,7 +60,6 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks):
async def demo_endpoint(severity: str = "CRITICAL"):
"""
Demo endpoint — run a sample finding through Concord synchronously.
- Perfect for the Friday review: hit this from /docs and see the full result.
Try: POST /events/demo?severity=CRITICAL
POST /events/demo?severity=LOW
@@ -78,3 +79,116 @@ async def demo_endpoint(severity: str = "CRITICAL"):
)
return await Orchestrator().process(finding)
+
+
+@router.get("/approvals/pending")
+async def list_pending_approvals(limit: int = 20):
+ """List findings awaiting a human approval decision."""
+ store = get_store()
+ pending = store.list_pending_approvals(limit=limit)
+ return {"pending": pending, "total": len(pending)}
+
+
+@router.post("/findings/{finding_id}/approve/{agent}")
+async def approve_finding(finding_id: str, agent: str):
+ """Approve a finding's tiebreak by selecting the winning agent."""
+ store = get_store()
+ record = store.get_finding(finding_id)
+ if record is None:
+ raise HTTPException(status_code=404, detail=f"Finding {finding_id!r} not found")
+
+ result = record.get("result", {})
+
+ # Validate agent is one of the candidates that actually ran
+ candidate_agents = result.get("agents", {})
+ if candidate_agents and agent not in candidate_agents:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Agent {agent!r} is not a candidate for finding {finding_id!r}. "
+ f"Valid agents: {list(candidate_agents.keys())}",
+ )
+
+ result["approved_by"] = agent
+ result["auto_resolved"] = True
+ persisted = store.update_finding_result(finding_id, result)
+
+ # Audit reason format must match: "human_approved:{agent}" (no space)
+ store.add_audit(AuditRecord(
+ finding_id=finding_id,
+ path=record.get("path", ""),
+ reason=f"human_approved:{agent}",
+ agent=agent,
+ ))
+
+ github_url = result.get("github_url")
+ return {
+ "status": "approved",
+ "finding_id": finding_id,
+ "agent": agent,
+ "persisted": persisted,
+ **({"github_url": github_url} if github_url else {}),
+ }
+
+
+@router.post("/findings/{finding_id}/reject")
+async def reject_finding(finding_id: str, reason: str = "rejected"):
+ """Reject a finding — marks it resolved so it leaves the pending queue."""
+ store = get_store()
+ record = store.get_finding(finding_id)
+ if record is None:
+ raise HTTPException(status_code=404, detail=f"Finding {finding_id!r} not found")
+
+ result = record.get("result", {})
+ result["rejected"] = True
+ result["reject_reason"] = reason
+ result["approved_by"] = "__rejected__"
+ result["auto_resolved"] = True
+ persisted = store.update_finding_result(finding_id, result)
+
+ store.add_audit(AuditRecord(
+ finding_id=finding_id,
+ path=record.get("path", ""),
+ reason=f"human_rejected:{reason}",
+ agent=None,
+ ))
+
+ return {
+ "status": "rejected",
+ "finding_id": finding_id,
+ "reason": reason,
+ "persisted": persisted,
+ }
+
+
+@router.post("/approvals/expire")
+async def expire_old_approvals(max_age_hours: int = 24):
+ """Expire pending approvals older than max_age_hours; audits each one."""
+ store = get_store()
+ pending = store.list_pending_approvals(limit=500)
+ cutoff = datetime.now(UTC) - timedelta(hours=max_age_hours)
+ expired = []
+
+ for record in pending:
+ ts_str = record.get("timestamp", "")
+ try:
+ ts = datetime.fromisoformat(ts_str)
+ if ts.tzinfo is None:
+ ts = ts.replace(tzinfo=UTC)
+ except ValueError:
+ continue
+
+ if ts < cutoff:
+ finding_id = record["id"]
+ result = record.get("result", {})
+ result["approved_by"] = "__expired__"
+ result["auto_resolved"] = True
+ store.update_finding_result(finding_id, result)
+ store.add_audit(AuditRecord(
+ finding_id=finding_id,
+ path=record.get("path", ""),
+ reason=f"approval_expired:{max_age_hours}h",
+ agent=None,
+ ))
+ expired.append(finding_id)
+
+ return {"status": "ok", "count": len(expired), "expired": expired}
diff --git a/api/routes/findings.py b/api/routes/findings.py
index cd4507c..17115dd 100644
--- a/api/routes/findings.py
+++ b/api/routes/findings.py
@@ -1,11 +1,6 @@
"""
api/routes/findings.py
Findings REST API backed by the persistent store (core.persistence).
-
-``store`` is kept as a thin adapter with the historical method names
-(``add``/``get``/``all``/``stats``) so existing callers such as
-api/routes/scan.py keep working, but every call now reads and writes the
-durable SQLite-backed store instead of an in-memory list.
"""
import os
@@ -32,8 +27,9 @@ def add(self, finding_id: str, severity: str, artifact: str,
result=result,
))
- def all(self, limit: int = 50) -> list:
- return get_store().list_findings(limit=limit)
+ def all(self, limit: int = 50, severity: str | None = None,
+ path: str | None = None) -> list:
+ return get_store().list_findings(limit=limit, severity=severity, path=path)
def get(self, finding_id: str) -> dict | None:
return get_store().get_finding(finding_id)
@@ -46,11 +42,44 @@ def stats(self) -> dict:
@router.get("/")
-async def list_findings(limit: int = 50):
+async def list_findings(limit: int = 50, severity: str | None = None,
+ path: str | None = None):
return {
- "findings": store.all(limit),
+ "findings": store.all(limit, severity=severity, path=path),
"stats": store.stats(),
"llm_provider": os.getenv("LLM_PROVIDER", "ollama"),
+ "filters": {"severity": severity, "path": path},
+ }
+
+
+@router.get("/severity")
+async def severity_breakdown():
+ """Findings grouped by severity (for the Security view)."""
+ return {"by_severity": get_store().severity_breakdown()}
+
+
+@router.get("/incidents")
+async def incidents(limit: int = 50):
+ """Findings grouped by affected artifact into incident summaries."""
+ inc = get_store().incidents(limit=limit)
+ return {"incidents": inc, "total": len(inc)}
+
+
+@router.get("/{finding_id}/detail")
+async def finding_detail(finding_id: str):
+ """A finding joined with its audit timeline (for the detail panel)."""
+ s = get_store()
+ f = s.get_finding(finding_id)
+ if not f:
+ raise HTTPException(status_code=404, detail="Finding not found")
+ result = f.get("result", {})
+ return {
+ "finding": f,
+ "agents": result.get("agents", {}),
+ "auto_resolved": result.get("auto_resolved"),
+ "approved_by": result.get("approved_by"),
+ "rejected": result.get("rejected", False),
+ "timeline": s.audit_for_finding(finding_id),
}
@@ -59,4 +88,4 @@ async def get_finding(finding_id: str):
f = store.get(finding_id)
if not f:
raise HTTPException(status_code=404, detail="Finding not found")
- return f
\ No newline at end of file
+ return f
diff --git a/api/templates/dashboard.html b/api/templates/dashboard.html
index f378bcb..77a6f0f 100644
--- a/api/templates/dashboard.html
+++ b/api/templates/dashboard.html
@@ -4,6 +4,7 @@
Concord — AI DevSecOps Platform
+