From ecf10146d472296a573ca33ad235739b82fb1c8a Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 03:51:54 -0400 Subject: [PATCH 01/14] feat(chain): add ChainCalibrationState model and migration Adds per-rule Bayesian calibration state table (alpha, beta_param, observations) for Phase 3C.3, with Alembic migration 007. Co-Authored-By: Claude Sonnet 4.6 --- .../versions/007_chain_calibration_state.py | 28 +++++++++++++++++++ packages/web/backend/app/models.py | 16 +++++++++++ 2 files changed, 44 insertions(+) create mode 100644 packages/web/backend/alembic/versions/007_chain_calibration_state.py diff --git a/packages/web/backend/alembic/versions/007_chain_calibration_state.py b/packages/web/backend/alembic/versions/007_chain_calibration_state.py new file mode 100644 index 0000000..1c38b22 --- /dev/null +++ b/packages/web/backend/alembic/versions/007_chain_calibration_state.py @@ -0,0 +1,28 @@ +"""Add chain_calibration_state table. + +Revision ID: 007 +Revises: 006 +""" +import sqlalchemy as sa +from alembic import op + +revision = "007" +down_revision = "006" + + +def upgrade() -> None: + op.create_table( + "chain_calibration_state", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("user_id", sa.Uuid(), sa.ForeignKey("user.id"), nullable=False, index=True), + sa.Column("rule", sa.String(), nullable=False, index=True), + sa.Column("alpha", sa.Float(), nullable=False, server_default="1.0"), + sa.Column("beta_param", sa.Float(), nullable=False, server_default="1.0"), + sa.Column("observations", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_calibrated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("user_id", "rule", name="uq_calibration_state"), + ) + + +def downgrade() -> None: + op.drop_table("chain_calibration_state") diff --git a/packages/web/backend/app/models.py b/packages/web/backend/app/models.py index b50db76..2b70c84 100644 --- a/packages/web/backend/app/models.py +++ b/packages/web/backend/app/models.py @@ -473,3 +473,19 @@ class ChainFindingParserOutput(SQLModel, table=True): user_id: Optional[uuid.UUID] = Field( default=None, foreign_key="user.id", index=True, nullable=True ) + + +class ChainCalibrationState(SQLModel, table=True): + """Per-rule Bayesian calibration state for a user.""" + __tablename__ = "chain_calibration_state" + id: str = Field(primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", index=True) + rule: str = Field(index=True) + alpha: float = Field(default=1.0) + beta_param: float = Field(default=1.0) + observations: int = Field(default=0) + last_calibrated_at: datetime = Field(**_TZ_KW) + + __table_args__ = ( + UniqueConstraint("user_id", "rule", name="uq_calibration_state"), + ) From f2f9985411e8f2f0ad2f54a98039800511e26062 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 03:53:46 -0400 Subject: [PATCH 02/14] feat(chain): Bayesian calibration service with Beta priors --- .../backend/app/services/chain_calibration.py | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 packages/web/backend/app/services/chain_calibration.py diff --git a/packages/web/backend/app/services/chain_calibration.py b/packages/web/backend/app/services/chain_calibration.py new file mode 100644 index 0000000..bd780b9 --- /dev/null +++ b/packages/web/backend/app/services/chain_calibration.py @@ -0,0 +1,207 @@ +"""Bayesian weight calibration service. + +Uses Beta distribution priors per linking rule, updated from user +confirm/reject decisions. Posterior mean = alpha / (alpha + beta_param) +estimates each rule's reliability. +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ChainCalibrationState, ChainFindingRelation + +# Default Beta priors per rule +DEFAULT_PRIORS: dict[str, tuple[float, float]] = { + "shared_strong_entity": (2.0, 1.0), + "cve_adjacency": (2.0, 1.0), + "temporal_proximity": (1.0, 1.0), + "kill_chain": (1.0, 1.0), + "tool_chain": (1.0, 1.0), + "cross_engagement_ioc": (1.0, 1.0), +} + +MINIMUM_DECISIONS = 20 + + +async def get_or_create_priors( + session: AsyncSession, user_id: uuid.UUID +) -> dict[str, ChainCalibrationState]: + """Load existing calibration state or seed defaults.""" + stmt = select(ChainCalibrationState).where( + ChainCalibrationState.user_id == user_id + ) + result = await session.execute(stmt) + existing = {row.rule: row for row in result.scalars()} + + now = datetime.now(timezone.utc) + for rule, (alpha, beta) in DEFAULT_PRIORS.items(): + if rule not in existing: + row = ChainCalibrationState( + id=f"cal-{user_id}-{rule}", + user_id=user_id, + rule=rule, + alpha=alpha, + beta_param=beta, + observations=0, + last_calibrated_at=now, + ) + session.add(row) + existing[rule] = row + + await session.flush() + return existing + + +async def count_user_decisions( + session: AsyncSession, user_id: uuid.UUID, engagement_id: str | None = None +) -> int: + """Count total user-confirmed + user-rejected edges.""" + stmt = select(func.count()).select_from(ChainFindingRelation).where( + ChainFindingRelation.user_id == user_id, + ChainFindingRelation.status.in_(["user_confirmed", "user_rejected"]), + ) + if engagement_id: + from app.models import Finding + finding_ids_stmt = select(Finding.id).where( + Finding.engagement_id == engagement_id, + Finding.user_id == user_id, + ) + stmt = stmt.where( + ChainFindingRelation.source_finding_id.in_(finding_ids_stmt) + ) + result = await session.execute(stmt) + return result.scalar() or 0 + + +async def calibrate( + session: AsyncSession, + *, + user_id: uuid.UUID, + engagement_id: str | None = None, + dry_run: bool = False, +) -> dict[str, Any]: + """Run Bayesian calibration from user decisions. + + Returns dict with 'rules' (per-rule posteriors), 'edges_updated', + 'below_threshold'. + """ + import orjson + + total_decisions = await count_user_decisions(session, user_id, engagement_id) + if total_decisions < MINIMUM_DECISIONS: + return { + "rules": [], + "edges_updated": 0, + "below_threshold": True, + "total_decisions": total_decisions, + "minimum_required": MINIMUM_DECISIONS, + } + + # Load or seed priors + priors = await get_or_create_priors(session, user_id) + + # Reset to defaults before re-counting + for rule, (alpha, beta) in DEFAULT_PRIORS.items(): + if rule in priors: + priors[rule].alpha = alpha + priors[rule].beta_param = beta + priors[rule].observations = 0 + + # Fetch all user-decided edges + decided_stmt = select(ChainFindingRelation).where( + ChainFindingRelation.user_id == user_id, + ChainFindingRelation.status.in_(["user_confirmed", "user_rejected"]), + ) + if engagement_id: + from app.models import Finding + finding_ids_stmt = select(Finding.id).where( + Finding.engagement_id == engagement_id, + Finding.user_id == user_id, + ) + decided_stmt = decided_stmt.where( + ChainFindingRelation.source_finding_id.in_(finding_ids_stmt) + ) + + decided_result = await session.execute(decided_stmt) + decided_edges = list(decided_result.scalars()) + + # Update priors from decisions + for edge in decided_edges: + reasons_data = orjson.loads(edge.reasons_json) if edge.reasons_json else [] + rules_fired = {r["rule"] for r in reasons_data if "rule" in r} + + for rule in rules_fired: + if rule not in priors: + continue + if edge.status == "user_confirmed": + priors[rule].alpha += 1 + elif edge.status == "user_rejected": + priors[rule].beta_param += 1 + priors[rule].observations += 1 + + now = datetime.now(timezone.utc) + for p in priors.values(): + p.last_calibrated_at = now + + # Build posteriors summary + rules_summary = [ + { + "rule": rule, + "alpha": priors[rule].alpha, + "beta": priors[rule].beta_param, + "posterior": priors[rule].alpha / (priors[rule].alpha + priors[rule].beta_param), + "observations": priors[rule].observations, + } + for rule in sorted(priors.keys()) + ] + + edges_updated = 0 + if not dry_run: + # Re-score all non-rejected edges with bayesian weights + posteriors = { + rule: priors[rule].alpha / (priors[rule].alpha + priors[rule].beta_param) + for rule in priors + } + + all_edges_stmt = select(ChainFindingRelation).where( + ChainFindingRelation.user_id == user_id, + ChainFindingRelation.status.notin_(["rejected", "user_rejected"]), + ) + all_result = await session.execute(all_edges_stmt) + all_edges = list(all_result.scalars()) + + for edge in all_edges: + reasons_data = orjson.loads(edge.reasons_json) if edge.reasons_json else [] + new_weight = 0.0 + for reason in reasons_data: + rule = reason.get("rule", "") + contribution = reason.get("weight_contribution", 0.0) + posterior = posteriors.get(rule, 1.0) + new_weight += contribution * posterior + + # Cap at 1.0 + new_weight = min(new_weight, 1.0) + + if abs(edge.weight - new_weight) > 0.001: + edge.weight = new_weight + edge.weight_model_version = "bayesian_v1" + edge.updated_at = now + edges_updated += 1 + + # Persist calibration state and edge updates + for p in priors.values(): + session.add(p) + await session.commit() + + return { + "rules": rules_summary, + "edges_updated": edges_updated, + "below_threshold": False, + "total_decisions": total_decisions, + "minimum_required": MINIMUM_DECISIONS, + } From 248aed94a455c496e4497e7a1d97fd1ec8310635 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 03:54:03 -0400 Subject: [PATCH 03/14] feat(chain): Markdown attack path report export service --- .../web/backend/app/services/chain_export.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/web/backend/app/services/chain_export.py diff --git a/packages/web/backend/app/services/chain_export.py b/packages/web/backend/app/services/chain_export.py new file mode 100644 index 0000000..c4dfe59 --- /dev/null +++ b/packages/web/backend/app/services/chain_export.py @@ -0,0 +1,137 @@ +"""Markdown attack path report generation.""" +from __future__ import annotations + +import math +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ChainFindingRelation, Engagement, Finding + + +async def export_path_markdown( + session: AsyncSession, + *, + user_id: uuid.UUID, + finding_ids: list[str], + engagement_id: str | None = None, +) -> str: + """Generate a Markdown attack path report from an ordered list of finding IDs.""" + import orjson + + # Fetch engagement name if provided + eng_name = "Unknown Engagement" + if engagement_id: + eng_stmt = select(Engagement).where( + Engagement.id == engagement_id, Engagement.user_id == user_id + ) + eng_result = await session.execute(eng_stmt) + eng = eng_result.scalar_one_or_none() + if eng: + eng_name = eng.name + + # Fetch all findings in order + findings: list[Any] = [] + for fid in finding_ids: + stmt = select(Finding).where(Finding.id == fid, Finding.user_id == user_id) + result = await session.execute(stmt) + f = result.scalar_one_or_none() + if f is None: + raise ValueError(f"Finding {fid} not found") + findings.append(f) + + # Fetch relations between consecutive findings + relations: list[Any] = [] + for i in range(len(findings) - 1): + src_id = findings[i].id + tgt_id = findings[i + 1].id + rel_stmt = select(ChainFindingRelation).where( + ChainFindingRelation.user_id == user_id, + ChainFindingRelation.source_finding_id == src_id, + ChainFindingRelation.target_finding_id == tgt_id, + ) + rel_result = await session.execute(rel_stmt) + rel = rel_result.scalar_one_or_none() + relations.append(rel) + + # Compute risk score + severity_multipliers = {"critical": 5, "high": 4, "medium": 3, "low": 2, "info": 1} + max_sev = max(severity_multipliers.get(f.severity, 1) for f in findings) + edge_weight_sum = sum(r.weight for r in relations if r) + hop_count = len(findings) - 1 + raw_score = (edge_weight_sum * max_sev) / max(math.sqrt(hop_count), 1) + risk_score = min(raw_score, 10.0) + + # Build markdown + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines = [ + "# Attack Path Report", + "", + f"**Engagement:** {eng_name}", + f"**Generated:** {now}", + f"**Path length:** {len(findings)} steps", + f"**Risk score:** {risk_score:.1f}/10", + "", + "## Summary", + "", + _build_summary(findings, relations), + "", + ] + + for i, finding in enumerate(findings): + sev = finding.severity.upper() if finding.severity else "UNKNOWN" + lines.append(f"## Step {i + 1}: {finding.title} ({sev})") + lines.append("") + lines.append(f"- **Tool:** {finding.tool}") + if finding.phase: + lines.append(f"- **Phase:** {finding.phase}") + if finding.evidence: + evidence = finding.evidence[:500] + lines.append(f"- **Evidence:** {evidence}") + if finding.remediation: + lines.append(f"- **Remediation:** {finding.remediation}") + + if i < len(relations) and relations[i]: + rel = relations[i] + reasons_data = orjson.loads(rel.reasons_json) if rel.reasons_json else [] + reason_names = [r.get("rule", "unknown") for r in reasons_data] + lines.append("") + lines.append( + f"**Link to Step {i + 2}:** {', '.join(reason_names)}, " + f"weight: {rel.weight:.2f}" + ) + lines.append("") + + # Recommendations + remediations = [f.remediation for f in findings if f.remediation] + if remediations: + lines.append("## Recommendations") + lines.append("") + seen = set() + for rem in remediations: + if rem not in seen: + seen.add(rem) + lines.append(f"{len(seen)}. {rem}") + lines.append("") + + return "\n".join(lines) + + +def _build_summary(findings: list, relations: list) -> str: + """Template-based path summary.""" + if not findings: + return "No findings in path." + + first = findings[0] + last = findings[-1] + steps = len(findings) + + return ( + f"This attack path spans {steps} steps, starting from " + f"**{first.title}** ({first.severity}) and culminating in " + f"**{last.title}** ({last.severity}). " + f"The path traverses {steps - 1} link(s) through the target environment." + ) From f80f4d2047cab1c933b669dd679e5ea6ef328605 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 03:56:33 -0400 Subject: [PATCH 04/14] feat(chain): global subgraph mode with pivotality, created_at, engagement meta Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/backend/app/services/chain_service.py | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/web/backend/app/services/chain_service.py b/packages/web/backend/app/services/chain_service.py index 345447f..8f13d06 100644 --- a/packages/web/backend/app/services/chain_service.py +++ b/packages/web/backend/app/services/chain_service.py @@ -250,7 +250,8 @@ async def subgraph_for_engagement( session: AsyncSession, *, user_id: uuid.UUID, - engagement_id: str, + engagement_id: str | None = None, + engagement_ids: list[str] | None = None, severities: set[str] | None = None, statuses: set[str] | None = None, max_nodes: int = 500, @@ -269,21 +270,21 @@ async def subgraph_for_engagement( store = chain_store_from_session(session) await store.initialize() - # Count total findings in engagement (for meta) - total_stmt = select(func.count()).select_from(Finding).where( - Finding.engagement_id == engagement_id, + # Fetch findings — scoped to engagement or global + finding_stmt = select(Finding).where( Finding.user_id == user_id, Finding.deleted_at.is_(None), ) + if engagement_id: + finding_stmt = finding_stmt.where(Finding.engagement_id == engagement_id) + elif engagement_ids: + finding_stmt = finding_stmt.where(Finding.engagement_id.in_(engagement_ids)) + + # Total count (before severity filter and cap) + total_stmt = select(func.count()).select_from(finding_stmt.subquery()) total_result = await session.execute(total_stmt) total_findings = total_result.scalar() or 0 - # Fetch findings for this engagement, applying severity filter - finding_stmt = select(Finding).where( - Finding.engagement_id == engagement_id, - Finding.user_id == user_id, - Finding.deleted_at.is_(None), - ) if severities: finding_stmt = finding_stmt.where(Finding.severity.in_(severities)) finding_stmt = finding_stmt.limit(max_nodes) @@ -327,6 +328,26 @@ async def subgraph_for_engagement( rel_result = await session.execute(rel_stmt) relations_orm = list(rel_result.scalars().all()) + # Compute betweenness centrality for pivotality scores + pivotality_scores: dict[str, float] = {} + if finding_ids and len(finding_ids) > 1: + import rustworkx as rx + g = rx.PyDiGraph() + id_to_idx: dict[str, int] = {} + for fid in finding_ids: + idx = g.add_node(fid) + id_to_idx[fid] = idx + for r in relations_orm: + src = r.source_finding_id + tgt = r.target_finding_id + if src in id_to_idx and tgt in id_to_idx: + g.add_edge(id_to_idx[src], id_to_idx[tgt], r.weight) + centrality = rx.betweenness_centrality(g) + max_c = max(centrality.values()) if centrality else 1.0 + for fid, idx in id_to_idx.items(): + raw = centrality.get(idx, 0.0) + pivotality_scores[fid] = raw / max_c if max_c > 0 else 0.0 + # Build nodes nodes = [ { @@ -335,6 +356,9 @@ async def subgraph_for_engagement( "severity": f.severity, "tool": f.tool, "phase": f.phase, + "created_at": f.created_at.isoformat() if f.created_at else None, + "engagement_id": f.engagement_id, + "pivotality": round(pivotality_scores.get(f.id, 0.0), 3), } for f in findings ] @@ -393,6 +417,18 @@ async def subgraph_for_engagement( }, } + # Collect distinct engagements represented in the result + from app.models import Engagement as EngModel + eng_ids_in_result = {f.engagement_id for f in findings} + engagements_meta = [] + if eng_ids_in_result: + eng_stmt = select(EngModel).where(EngModel.id.in_(eng_ids_in_result)) + eng_result = await session.execute(eng_stmt) + engagements_meta = [ + {"id": e.id, "name": e.name} + for e in eng_result.scalars() + ] + return { "graph": graph, "meta": { @@ -400,6 +436,7 @@ async def subgraph_for_engagement( "rendered_findings": len(findings), "filtered": bool(severities) or len(findings) < total_findings, "generation": generation, + "engagements": engagements_meta, }, } From 5a3a361745506b61a17e8cf483b0f4fb2d730924 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 03:58:29 -0400 Subject: [PATCH 05/14] feat(chain): calibrate, export, and global subgraph endpoints Co-Authored-By: Claude Sonnet 4.6 --- packages/web/backend/app/routes/chain.py | 87 +++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/web/backend/app/routes/chain.py b/packages/web/backend/app/routes/chain.py index 669bc94..2210f6e 100644 --- a/packages/web/backend/app/routes/chain.py +++ b/packages/web/backend/app/routes/chain.py @@ -88,6 +88,7 @@ class SubgraphMeta(BaseModel): rendered_findings: int filtered: bool generation: int + engagements: list[dict] = [] class SubgraphResponse(BaseModel): @@ -99,6 +100,25 @@ class RelationStatusUpdate(BaseModel): status: str +class CalibrateRequest(BaseModel): + scope: str = "user" + engagement_id: Optional[str] = None + dry_run: bool = False + + +class CalibrateResponse(BaseModel): + rules: list[dict] + edges_updated: int + below_threshold: bool + total_decisions: int + minimum_required: int + + +class ExportPathRequest(BaseModel): + finding_ids: list[str] + engagement_id: Optional[str] = None + + def get_chain_service() -> ChainService: return ChainService() @@ -267,7 +287,8 @@ async def get_run_status( @router.get("/subgraph", response_model=SubgraphResponse) async def get_subgraph( - engagement_id: str, + engagement_id: Optional[str] = None, + engagement_ids: Optional[str] = None, severity: Optional[str] = None, status_filter: Optional[str] = Query(default=None, alias="status"), max_nodes: int = 500, @@ -280,11 +301,13 @@ async def get_subgraph( ) -> SubgraphResponse: severities = set(severity.split(",")) if severity else None statuses = set(status_filter.split(",")) if status_filter else None + eng_ids_list = engagement_ids.split(",") if engagement_ids else None result = await service.subgraph_for_engagement( db, user_id=user.id, engagement_id=engagement_id, + engagement_ids=eng_ids_list, severities=severities, statuses=statuses, max_nodes=max_nodes, @@ -319,3 +342,65 @@ async def update_relation( if result is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="relation not found") return result + + +@router.post("/calibrate", response_model=CalibrateResponse) +async def calibrate_weights( + request: CalibrateRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> CalibrateResponse: + from app.services.chain_calibration import calibrate + + if request.scope not in ("user", "engagement"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="scope must be 'user' or 'engagement'", + ) + if request.scope == "engagement" and not request.engagement_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="engagement_id required when scope is 'engagement'", + ) + + result = await calibrate( + db, + user_id=user.id, + engagement_id=request.engagement_id if request.scope == "engagement" else None, + dry_run=request.dry_run, + ) + + if result["below_threshold"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Need at least {result['minimum_required']} user decisions, have {result['total_decisions']}", + ) + + return CalibrateResponse(**result) + + +@router.post("/export/path") +async def export_path( + request: ExportPathRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + from app.services.chain_export import export_path_markdown + + if len(request.finding_ids) < 2: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Path must contain at least 2 findings", + ) + + try: + markdown = await export_path_markdown( + db, + user_id=user.id, + finding_ids=request.finding_ids, + engagement_id=request.engagement_id, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + return {"markdown": markdown} From 14f6a423bcb39959f2bf5d310bb09fbfff88f4c6 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:00:26 -0400 Subject: [PATCH 06/14] feat(frontend): add global chain route and nav item Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/web/frontend/src/components/AppLayout.vue | 1 + packages/web/frontend/src/router/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/web/frontend/src/components/AppLayout.vue b/packages/web/frontend/src/components/AppLayout.vue index b4f19f2..a82d439 100644 --- a/packages/web/frontend/src/components/AppLayout.vue +++ b/packages/web/frontend/src/components/AppLayout.vue @@ -14,6 +14,7 @@ const menuItems = [ { label: 'Engagements', icon: 'pi pi-shield', command: () => router.push('/engagements') }, { label: 'Recipes', icon: 'pi pi-play', command: () => router.push('/recipes') }, { label: 'Containers', icon: 'pi pi-box', command: () => router.push('/containers') }, + { label: 'Attack Chain', icon: 'pi pi-share-alt', command: () => router.push('/chain/global') }, { label: 'IOCs', icon: 'pi pi-search', items: [ diff --git a/packages/web/frontend/src/router/index.ts b/packages/web/frontend/src/router/index.ts index fb4b352..b0042de 100644 --- a/packages/web/frontend/src/router/index.ts +++ b/packages/web/frontend/src/router/index.ts @@ -12,6 +12,7 @@ const router = createRouter({ { path: '/engagements/:id', name: 'engagement-detail', component: () => import('@/views/EngagementDetailView.vue') }, { path: '/findings/:id', name: 'finding-detail', component: () => import('@/views/FindingDetailView.vue') }, { path: '/engagements/:id/chain', name: 'engagement-chain', component: () => import('@/views/ChainGraphView.vue') }, + { path: '/chain/global', name: 'chain-global', component: () => import('@/views/GlobalChainView.vue') }, { path: '/recipes', name: 'recipes', component: () => import('@/views/RecipeListView.vue') }, { path: '/recipes/:id/run', name: 'recipe-run', component: () => import('@/views/RecipeRunnerView.vue') }, { path: '/containers', name: 'containers', component: () => import('@/views/ContainerStatusView.vue') }, From a0199f2c7030d12f7d265beeb11a55bdddf0d1d6 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:00:46 -0400 Subject: [PATCH 07/14] test(chain): global subgraph, engagement filter, new node fields Also fixes rustworkx CentralityMapping.get() AttributeError in chain_service by converting to dict before lookup. Co-Authored-By: Claude Sonnet 4.6 --- .../web/backend/app/services/chain_service.py | 5 +- .../web/backend/tests/test_chain_global.py | 119 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/web/backend/tests/test_chain_global.py diff --git a/packages/web/backend/app/services/chain_service.py b/packages/web/backend/app/services/chain_service.py index 8f13d06..b86ce8b 100644 --- a/packages/web/backend/app/services/chain_service.py +++ b/packages/web/backend/app/services/chain_service.py @@ -343,9 +343,10 @@ async def subgraph_for_engagement( if src in id_to_idx and tgt in id_to_idx: g.add_edge(id_to_idx[src], id_to_idx[tgt], r.weight) centrality = rx.betweenness_centrality(g) - max_c = max(centrality.values()) if centrality else 1.0 + centrality_dict = dict(centrality) + max_c = max(centrality_dict.values()) if centrality_dict else 1.0 for fid, idx in id_to_idx.items(): - raw = centrality.get(idx, 0.0) + raw = centrality_dict.get(idx, 0.0) pivotality_scores[fid] = raw / max_c if max_c > 0 else 0.0 # Build nodes diff --git a/packages/web/backend/tests/test_chain_global.py b/packages/web/backend/tests/test_chain_global.py new file mode 100644 index 0000000..401a406 --- /dev/null +++ b/packages/web/backend/tests/test_chain_global.py @@ -0,0 +1,119 @@ +"""Global subgraph endpoint tests (Phase 3C.3).""" + +import uuid +from datetime import datetime, timezone + +import pytest + +from app.models import ChainFindingRelation, Engagement, Finding +from tests.conftest import test_session_factory + +NOW = datetime.now(timezone.utc) + + +async def _get_user_id(auth_client) -> uuid.UUID: + eng_resp = await auth_client.post("/api/v1/engagements", json={ + "name": "_uid_probe", "target": "127.0.0.1", "type": "pentest", + }) + assert eng_resp.status_code == 201 + eng_id = eng_resp.json()["id"] + async with test_session_factory() as session: + from sqlalchemy import select + from app.models import Engagement as Eng + result = await session.execute(select(Eng).where(Eng.id == eng_id)) + eng = result.scalar_one() + return eng.user_id + + +async def _seed(user_id): + """Seed two engagements with findings and a cross-engagement relation.""" + async with test_session_factory() as session: + session.add(Engagement( + id="eng-g1", user_id=user_id, name="Pentest Q1", target="10.0.0.0/24", + type="pentest", created_at=NOW, updated_at=NOW, + )) + session.add(Engagement( + id="eng-g2", user_id=user_id, name="Web App", target="app.example.com", + type="pentest", created_at=NOW, updated_at=NOW, + )) + await session.flush() + session.add(Finding( + id="f-g1", user_id=user_id, engagement_id="eng-g1", + tool="nmap", severity="high", title="Open SSH", created_at=NOW, + )) + session.add(Finding( + id="f-g2", user_id=user_id, engagement_id="eng-g2", + tool="nuclei", severity="critical", title="RCE in /api", created_at=NOW, + )) + await session.flush() + session.add(ChainFindingRelation( + id="rel-cross", user_id=user_id, source_finding_id="f-g1", + target_finding_id="f-g2", weight=0.6, status="auto_confirmed", + symmetric=False, created_at=NOW, updated_at=NOW, + )) + await session.commit() + + +@pytest.mark.asyncio +async def test_global_subgraph_returns_cross_engagement(auth_client): + """Omitting engagement_id returns findings from all engagements.""" + user_id = await _get_user_id(auth_client) + await _seed(user_id) + + resp = await auth_client.get("/api/chain/subgraph?max_nodes=100") + assert resp.status_code == 200 + data = resp.json() + node_ids = {n["id"] for n in data["graph"]["nodes"]} + assert "f-g1" in node_ids + assert "f-g2" in node_ids + assert len(data["graph"]["links"]) >= 1 + + +@pytest.mark.asyncio +async def test_global_subgraph_includes_engagements_meta(auth_client): + """Meta includes engagements array with id and name.""" + user_id = await _get_user_id(auth_client) + await _seed(user_id) + + resp = await auth_client.get("/api/chain/subgraph?max_nodes=100") + data = resp.json() + eng_ids = {e["id"] for e in data["meta"]["engagements"]} + assert "eng-g1" in eng_ids + assert "eng-g2" in eng_ids + + +@pytest.mark.asyncio +async def test_global_subgraph_engagement_ids_filter(auth_client): + """engagement_ids param filters to specific engagements.""" + user_id = await _get_user_id(auth_client) + await _seed(user_id) + + resp = await auth_client.get("/api/chain/subgraph?engagement_ids=eng-g1&max_nodes=100") + data = resp.json() + for n in data["graph"]["nodes"]: + assert n["engagement_id"] == "eng-g1" + + +@pytest.mark.asyncio +async def test_subgraph_nodes_have_created_at(auth_client): + """Node objects include created_at field.""" + user_id = await _get_user_id(auth_client) + await _seed(user_id) + + resp = await auth_client.get("/api/chain/subgraph?engagement_id=eng-g1") + data = resp.json() + for n in data["graph"]["nodes"]: + assert "created_at" in n + + +@pytest.mark.asyncio +async def test_subgraph_nodes_have_pivotality(auth_client): + """Node objects include pivotality field.""" + user_id = await _get_user_id(auth_client) + await _seed(user_id) + + resp = await auth_client.get("/api/chain/subgraph?max_nodes=100") + data = resp.json() + for n in data["graph"]["nodes"]: + assert "pivotality" in n + assert isinstance(n["pivotality"], (int, float)) From ca299a2ee14aae0f887c28ddf4f2e26a11e8103f Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:01:20 -0400 Subject: [PATCH 08/14] feat(frontend): EngagementFilterChips and ChainTimelineScrubber components Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ChainTimelineScrubber.vue | 173 ++++++++++++++++++ .../src/components/EngagementFilterChips.vue | 68 +++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/web/frontend/src/components/ChainTimelineScrubber.vue create mode 100644 packages/web/frontend/src/components/EngagementFilterChips.vue diff --git a/packages/web/frontend/src/components/ChainTimelineScrubber.vue b/packages/web/frontend/src/components/ChainTimelineScrubber.vue new file mode 100644 index 0000000..0bad519 --- /dev/null +++ b/packages/web/frontend/src/components/ChainTimelineScrubber.vue @@ -0,0 +1,173 @@ + + + diff --git a/packages/web/frontend/src/components/EngagementFilterChips.vue b/packages/web/frontend/src/components/EngagementFilterChips.vue new file mode 100644 index 0000000..4a69b37 --- /dev/null +++ b/packages/web/frontend/src/components/EngagementFilterChips.vue @@ -0,0 +1,68 @@ + + + From b853215851fa47054962687452519f01b67abbdb Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:01:22 -0400 Subject: [PATCH 09/14] test(chain): calibration and export endpoint tests Adds Phase 3C.3 test coverage for /api/chain/calibrate (Bayesian below-threshold, success, dry-run, invalid scope) and /api/chain/export/path (markdown output, 404 on missing findings, 422 on <2 findings). All 7 tests pass against the SQLite in-memory test backend. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/tests/test_chain_calibration.py | 104 ++++++++++++++++++ .../web/backend/tests/test_chain_export.py | 98 +++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 packages/web/backend/tests/test_chain_calibration.py create mode 100644 packages/web/backend/tests/test_chain_export.py diff --git a/packages/web/backend/tests/test_chain_calibration.py b/packages/web/backend/tests/test_chain_calibration.py new file mode 100644 index 0000000..3ad4d27 --- /dev/null +++ b/packages/web/backend/tests/test_chain_calibration.py @@ -0,0 +1,104 @@ +"""Calibration endpoint tests (Phase 3C.3).""" + +import uuid +from datetime import datetime, timezone + +import pytest + +from app.models import ChainFindingRelation, Engagement, Finding +from tests.conftest import test_session_factory + +NOW = datetime.now(timezone.utc) + + +async def _get_user_id(auth_client) -> uuid.UUID: + eng_resp = await auth_client.post("/api/v1/engagements", json={ + "name": "_uid_probe", "target": "127.0.0.1", "type": "pentest", + }) + assert eng_resp.status_code == 201 + eng_id = eng_resp.json()["id"] + async with test_session_factory() as session: + from sqlalchemy import select + from app.models import Engagement as Eng + result = await session.execute(select(Eng).where(Eng.id == eng_id)) + eng = result.scalar_one() + return eng.user_id + + +async def _seed_decisions(user_id, count=25, confirmed_ratio=0.8): + """Seed engagement, findings, and user-decided edges.""" + async with test_session_factory() as session: + session.add(Engagement( + id="eng-cal", user_id=user_id, name="Cal Test", target="10.0.0.1", + type="pentest", created_at=NOW, updated_at=NOW, + )) + await session.flush() + + for i in range(count): + f1_id = f"f-cal-{i}-a" + f2_id = f"f-cal-{i}-b" + session.add(Finding( + id=f1_id, user_id=user_id, engagement_id="eng-cal", + tool="nmap", severity="high", title=f"Finding {f1_id}", created_at=NOW, + )) + session.add(Finding( + id=f2_id, user_id=user_id, engagement_id="eng-cal", + tool="nuclei", severity="medium", title=f"Finding {f2_id}", created_at=NOW, + )) + await session.flush() + + is_confirmed = i < int(count * confirmed_ratio) + session.add(ChainFindingRelation( + id=f"rel-cal-{i}", user_id=user_id, + source_finding_id=f1_id, target_finding_id=f2_id, + weight=0.5, status="user_confirmed" if is_confirmed else "user_rejected", + symmetric=False, + reasons_json=f'[{{"rule":"shared_strong_entity","weight_contribution":0.5,"idf_factor":null,"details":{{}}}}]', + created_at=NOW, updated_at=NOW, + )) + + await session.commit() + + +@pytest.mark.asyncio +async def test_calibrate_below_threshold(auth_client): + """Calibration with too few decisions returns 422.""" + resp = await auth_client.post("/api/chain/calibrate", json={"scope": "user"}) + assert resp.status_code == 422 + assert "Need at least" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_calibrate_success(auth_client): + """Calibration with enough decisions returns posteriors.""" + user_id = await _get_user_id(auth_client) + await _seed_decisions(user_id, count=25, confirmed_ratio=0.8) + + resp = await auth_client.post("/api/chain/calibrate", json={"scope": "user"}) + assert resp.status_code == 200 + data = resp.json() + assert data["below_threshold"] is False + assert len(data["rules"]) > 0 + + sse = next(r for r in data["rules"] if r["rule"] == "shared_strong_entity") + assert sse["posterior"] > 0.5 + + +@pytest.mark.asyncio +async def test_calibrate_dry_run(auth_client): + """Dry run returns posteriors but edges_updated=0.""" + user_id = await _get_user_id(auth_client) + await _seed_decisions(user_id, count=25) + + resp = await auth_client.post("/api/chain/calibrate", json={ + "scope": "user", "dry_run": True, + }) + assert resp.status_code == 200 + assert resp.json()["edges_updated"] == 0 + + +@pytest.mark.asyncio +async def test_calibrate_invalid_scope(auth_client): + """Invalid scope returns 422.""" + resp = await auth_client.post("/api/chain/calibrate", json={"scope": "global"}) + assert resp.status_code == 422 diff --git a/packages/web/backend/tests/test_chain_export.py b/packages/web/backend/tests/test_chain_export.py new file mode 100644 index 0000000..ad3df32 --- /dev/null +++ b/packages/web/backend/tests/test_chain_export.py @@ -0,0 +1,98 @@ +"""Export endpoint tests (Phase 3C.3).""" + +import uuid +from datetime import datetime, timezone + +import pytest + +from app.models import ChainFindingRelation, Engagement, Finding +from tests.conftest import test_session_factory + +NOW = datetime.now(timezone.utc) + + +async def _get_user_id(auth_client) -> uuid.UUID: + eng_resp = await auth_client.post("/api/v1/engagements", json={ + "name": "_uid_probe", "target": "127.0.0.1", "type": "pentest", + }) + assert eng_resp.status_code == 201 + eng_id = eng_resp.json()["id"] + async with test_session_factory() as session: + from sqlalchemy import select + from app.models import Engagement as Eng + result = await session.execute(select(Eng).where(Eng.id == eng_id)) + eng = result.scalar_one() + return eng.user_id + + +async def _seed_path(user_id): + """Seed engagement with a 3-step path.""" + async with test_session_factory() as session: + session.add(Engagement( + id="eng-exp", user_id=user_id, name="Export Test", target="10.0.0.1", + type="pentest", created_at=NOW, updated_at=NOW, + )) + await session.flush() + for i, (sev, title) in enumerate([ + ("critical", "SQL Injection"), + ("high", "Credential Dump"), + ("medium", "Lateral Movement"), + ]): + session.add(Finding( + id=f"f-exp-{i}", user_id=user_id, engagement_id="eng-exp", + tool="test", severity=sev, title=title, created_at=NOW, + evidence=f"Evidence for step {i}", + remediation=f"Fix step {i}", + )) + await session.flush() + session.add(ChainFindingRelation( + id="rel-exp-0", user_id=user_id, source_finding_id="f-exp-0", + target_finding_id="f-exp-1", weight=0.9, status="auto_confirmed", + symmetric=False, reasons_json='[{"rule":"shared_strong_entity","weight_contribution":0.9}]', + created_at=NOW, updated_at=NOW, + )) + session.add(ChainFindingRelation( + id="rel-exp-1", user_id=user_id, source_finding_id="f-exp-1", + target_finding_id="f-exp-2", weight=0.7, status="auto_confirmed", + symmetric=False, reasons_json='[{"rule":"temporal_proximity","weight_contribution":0.7}]', + created_at=NOW, updated_at=NOW, + )) + await session.commit() + + +@pytest.mark.asyncio +async def test_export_path_returns_markdown(auth_client): + """Valid path returns Markdown with expected sections.""" + user_id = await _get_user_id(auth_client) + await _seed_path(user_id) + + resp = await auth_client.post("/api/chain/export/path", json={ + "finding_ids": ["f-exp-0", "f-exp-1", "f-exp-2"], + "engagement_id": "eng-exp", + }) + assert resp.status_code == 200 + md = resp.json()["markdown"] + assert "# Attack Path Report" in md + assert "SQL Injection" in md + assert "Step 1:" in md + assert "Step 2:" in md + assert "Step 3:" in md + assert "Recommendations" in md + + +@pytest.mark.asyncio +async def test_export_path_invalid_finding(auth_client): + """Invalid finding ID returns 404.""" + resp = await auth_client.post("/api/chain/export/path", json={ + "finding_ids": ["f-nonexistent-1", "f-nonexistent-2"], + }) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_export_path_too_short(auth_client): + """Path with <2 findings returns 422.""" + resp = await auth_client.post("/api/chain/export/path", json={ + "finding_ids": ["f-exp-0"], + }) + assert resp.status_code == 422 From 594fa4a2d29f7344e718e262d3d058e839745a0a Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:04:16 -0400 Subject: [PATCH 10/14] =?UTF-8?q?feat(frontend):=20ForceGraphCanvas=20?= =?UTF-8?q?=E2=80=94=20timeline=20filter,=20kill=20chain=20layout,=20pivot?= =?UTF-8?q?ality=20glow,=20engagement=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ForceGraphCanvas.vue | 167 +++++++++++++++++- 1 file changed, 162 insertions(+), 5 deletions(-) diff --git a/packages/web/frontend/src/components/ForceGraphCanvas.vue b/packages/web/frontend/src/components/ForceGraphCanvas.vue index 89b15a9..f20c510 100644 --- a/packages/web/frontend/src/components/ForceGraphCanvas.vue +++ b/packages/web/frontend/src/components/ForceGraphCanvas.vue @@ -13,6 +13,9 @@ interface GraphNode { fx?: number | undefined fy?: number | undefined neighborCount?: number + created_at?: string | null + engagement_id?: string + pivotality?: number } interface GraphLink { @@ -32,11 +35,20 @@ interface GraphData { links: GraphLink[] } -const props = defineProps<{ +const props = withDefaults(defineProps<{ data: GraphData selectedNodeId: string | null selectedLinkId: string | null -}>() + timeRange?: { start: Date; end: Date } | null + layoutMode?: 'force' | 'killchain' + colorMode?: 'severity' | 'engagement' + engagementColors?: Record +}>(), { + timeRange: null, + layoutMode: 'force', + colorMode: 'severity', + engagementColors: () => ({}), +}) const emit = defineEmits<{ (e: 'node-click', node: GraphNode): void @@ -72,6 +84,37 @@ const MITRE_ABBREVS: Record = { 'impact': 'IM', } +const KILL_CHAIN_PHASES = [ + 'reconnaissance', 'resource-development', 'initial-access', 'execution', + 'persistence', 'privilege-escalation', 'defense-evasion', 'credential-access', + 'discovery', 'lateral-movement', 'collection', 'command-and-control', + 'exfiltration', 'impact', +] + +function applyKillChainLayout() { + if (!graph || !container.value) return + const width = container.value.clientWidth + const laneCount = KILL_CHAIN_PHASES.length + 1 + const laneWidth = width / laneCount + + const nodes = graph.graphData().nodes as GraphNode[] + for (const n of nodes) { + const phaseIdx = n.phase ? KILL_CHAIN_PHASES.indexOf(n.phase) : -1 + const lane = phaseIdx >= 0 ? phaseIdx : KILL_CHAIN_PHASES.length + n.fx = laneWidth * lane + laneWidth / 2 + } + graph.d3ReheatSimulation() +} + +function clearKillChainLayout() { + if (!graph) return + const nodes = graph.graphData().nodes as GraphNode[] + for (const n of nodes) { + n.fx = undefined + } + graph.d3ReheatSimulation() +} + function getNodeId(ref: string | GraphNode): string { return typeof ref === 'string' ? ref : ref.id } @@ -92,11 +135,31 @@ function initGraph() { .linkTarget('target') .nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const n = node as GraphNode + + // Time range visibility + if (props.timeRange && n.created_at) { + const t = new Date(n.created_at).getTime() + if (t < props.timeRange.start.getTime() || t > props.timeRange.end.getTime()) { + return // Don't render — outside time window + } + } + const connCount = countConnections(n.id) const radius = Math.min(4 + connCount * 0.8, 12) - const color = SEVERITY_COLORS[n.severity] || '#95a5a6' + const color = props.colorMode === 'engagement' && n.engagement_id + ? (props.engagementColors[n.engagement_id] || '#95a5a6') + : (SEVERITY_COLORS[n.severity] || '#95a5a6') const isSelected = n.id === props.selectedNodeId + // Pivotality glow + if (n.pivotality && n.pivotality > 0.1) { + const glowRadius = radius + 4 + n.pivotality * 8 + ctx.beginPath() + ctx.arc(node.x, node.y, glowRadius, 0, 2 * Math.PI) + ctx.fillStyle = `rgba(251, 191, 36, ${n.pivotality * 0.3})` + ctx.fill() + } + // Circle ctx.beginPath() ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) @@ -115,6 +178,16 @@ function initGraph() { ctx.stroke() } + // Severity ring in engagement color mode + if (props.colorMode === 'engagement') { + const sevColor = SEVERITY_COLORS[n.severity] || '#95a5a6' + ctx.beginPath() + ctx.arc(node.x, node.y, radius + 2 / globalScale, 0, 2 * Math.PI) + ctx.strokeStyle = sevColor + ctx.lineWidth = 1.5 / globalScale + ctx.stroke() + } + // Label (visible at medium+ zoom) if (globalScale > 1.5) { const label = n.name.length > 30 ? n.name.slice(0, 27) + '\u2026' : n.name @@ -165,6 +238,20 @@ function initGraph() { const tgt = link.target if (!src.x || !tgt.x) return + // Hide edges where either endpoint is outside time window + if (props.timeRange) { + const srcNode = src as GraphNode + const tgtNode = tgt as GraphNode + if (srcNode.created_at) { + const st = new Date(srcNode.created_at).getTime() + if (st < props.timeRange.start.getTime() || st > props.timeRange.end.getTime()) return + } + if (tgtNode.created_at) { + const tt = new Date(tgtNode.created_at).getTime() + if (tt < props.timeRange.start.getTime() || tt > props.timeRange.end.getTime()) return + } + } + const isSelected = l.id === props.selectedLinkId // Style by status @@ -172,9 +259,33 @@ function initGraph() { const isCandidate = l.status === 'candidate' const isRejected = l.status === 'rejected' || l.status === 'user_rejected' + // Draw path — bezier in kill chain mode, straight in force mode ctx.beginPath() - ctx.moveTo(src.x, src.y) - ctx.lineTo(tgt.x, tgt.y) + if (props.layoutMode === 'killchain') { + const dx = tgt.x - src.x + const dy = tgt.y - src.y + const dist = Math.sqrt(dx * dx + dy * dy) + const midX = (src.x + tgt.x) / 2 + const midY = (src.y + tgt.y) / 2 + + ctx.moveTo(src.x, src.y) + if (Math.abs(dx) < 30) { + // Intra-lane: arc + const cpX = midX + dist * 0.3 + ctx.quadraticCurveTo(cpX, midY, tgt.x, tgt.y) + } else { + // Inter-lane: bezier + const cpOffset = Math.min(dist * 0.2, 50) + ctx.bezierCurveTo( + src.x + dx * 0.25, src.y - cpOffset, + tgt.x - dx * 0.25, tgt.y - cpOffset, + tgt.x, tgt.y + ) + } + } else { + ctx.moveTo(src.x, src.y) + ctx.lineTo(tgt.x, tgt.y) + } if (isRejected) { ctx.strokeStyle = 'rgba(231, 76, 60, 0.4)' @@ -242,6 +353,44 @@ function initGraph() { .onBackgroundClick(() => emit('background-click')) .cooldownTicks(100) .warmupTicks(50) + .onRenderFramePost((ctx: CanvasRenderingContext2D, globalScale: number) => { + if (props.layoutMode !== 'killchain' || !container.value) return + + const width = container.value.clientWidth + const height = container.value.clientHeight + const laneCount = KILL_CHAIN_PHASES.length + 1 + const laneWidth = width / laneCount + + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + + for (let i = 0; i <= laneCount; i++) { + const x = i * laneWidth + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, height) + ctx.strokeStyle = 'rgba(150, 150, 150, 0.2)' + ctx.setLineDash([4, 4]) + ctx.lineWidth = 1 + ctx.stroke() + ctx.setLineDash([]) + + if (i < KILL_CHAIN_PHASES.length) { + const label = MITRE_ABBREVS[KILL_CHAIN_PHASES[i]] || KILL_CHAIN_PHASES[i].slice(0, 4) + ctx.font = '10px sans-serif' + ctx.fillStyle = 'rgba(150, 150, 150, 0.6)' + ctx.textAlign = 'center' + ctx.fillText(label, x + laneWidth / 2, 14) + } else if (i === KILL_CHAIN_PHASES.length) { + ctx.font = '10px sans-serif' + ctx.fillStyle = 'rgba(150, 150, 150, 0.6)' + ctx.textAlign = 'center' + ctx.fillText('Other', x + laneWidth / 2, 14) + } + } + + ctx.restore() + }) // Zoom to fit after initial layout setTimeout(() => graph?.zoomToFit(400, 50), 500) @@ -283,6 +432,14 @@ watch(() => props.data, (newData) => { } }, { deep: true }) +watch(() => props.layoutMode, (mode) => { + if (mode === 'killchain') { + applyKillChainLayout() + } else { + clearKillChainLayout() + } +}) + onMounted(() => { nextTick(() => initGraph()) }) From 4eefeb4d5f9f2de6e21461bc984b4bc545fc0162 Mon Sep 17 00:00:00 2001 From: Emperiusm Date: Mon, 13 Apr 2026 04:05:56 -0400 Subject: [PATCH 11/14] =?UTF-8?q?feat(frontend):=20ChainDetailPanel=20?= =?UTF-8?q?=E2=80=94=20calibrated=20badge,=20export=20button,=20pivotality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ChainDetailPanel.vue | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/web/frontend/src/components/ChainDetailPanel.vue b/packages/web/frontend/src/components/ChainDetailPanel.vue index 277f3a0..d083ab9 100644 --- a/packages/web/frontend/src/components/ChainDetailPanel.vue +++ b/packages/web/frontend/src/components/ChainDetailPanel.vue @@ -10,6 +10,7 @@ interface GraphNode { tool: string phase: string | null neighborCount?: number + pivotality?: number } interface GraphLink { @@ -22,6 +23,7 @@ interface GraphLink { reasons: string[] relation_type: string | null rationale: string | null + weight_model_version?: string } const props = defineProps<{ @@ -35,6 +37,7 @@ const emit = defineEmits<{ (e: 'confirm', linkId: string): void (e: 'reject', linkId: string): void (e: 'expand', nodeId: string): void + (e: 'export-path'): void }>() function findNode(ref: string | { id: string }): GraphNode | undefined { @@ -86,6 +89,9 @@ function getStatusDisplay(status: string) {
Neighbors: {{ selectedNode.neighborCount }}
+
+ Pivotality: {{ (selectedNode.pivotality * 100).toFixed(0) }}% +