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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Attack graph Phase 3c polish** — FixKind registry with runtime `graph_mutate` engine (apply registry `mutates` and simulate template elimination), inventory multi-server graph layer (`graph_inventory`), counterfactual fix simulation on paths, default-on counterfactuals and UI compression (`--no-attack-graph-counterfactuals`, `--no-attack-graph-compress-ui`), dashboard layer filter + policy/inferred edge styling, `mcts doctor --suggest-fixes --report`

- **Attack graph v3 rollout (Phase 3a/3b)** — default `attack_graph_version=3`; YAML template matcher replaces `AttackChainAnalyzer`; 12 chain templates including `SSRF_RESOURCE`, `ENV_SAMPLING`, `GIT_UNSCOPED`, `PROMPT_BYPASS`, `ELICIT_PHISH`, `TOCTOU_READ`, `READ_EXEC`, `CRED_THEFT`; capability overlap fallbacks; dashboard v3 paths + SARIF `mcts/attackPathExplanation`; R-23–R-25 regression fixtures + `tests/scoring/test_phase_3b_templates.py`
- **Fact provenance metrics** — `fact_coverage()` reports `native_pct` / `silver_pct`; dashboard exposes `fact_provenance`; CI gates via `check_ttu_baseline.py` + corpus `--check-only`
- **Scoring corpus** — `single_tool_overlap` fixture under enforce; Spearman calibration validates without mutating fixtures in CI
Expand Down
40 changes: 40 additions & 0 deletions src/mcts/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def run_doctor(
deep: bool = False,
json_output: bool = False,
output: Path | None = None,
suggest_fixes: bool = False,
report: Path | None = None,
) -> int:
"""Run read-only preflight checks. Returns exit code (0 ok, 1 failures, 2 user error)."""
root = path.expanduser().resolve()
Expand Down Expand Up @@ -112,12 +114,44 @@ def run_doctor(
if deep:
warnings += _check_optional_toolchain(checks)

fix_suggestions: list[dict] = []
if suggest_fixes:
if report is None:
checks.append(
(
"warn",
"Suggest fixes",
"pass --report scan.json to list attack-graph remediations",
)
)
warnings += 1
elif not report.exists():
checks.append(("fail", "Suggest fixes", f"report not found: {report}"))
failures += 1
else:
from mcts.scoring.graph_suggest import suggest_fixes_from_report

fix_suggestions = suggest_fixes_from_report(report)
if fix_suggestions:
checks.append(
(
"pass",
"Attack graph fixes",
f"{len(fix_suggestions)} template(s) with remediations",
)
)
else:
checks.append(("warn", "Attack graph fixes", "no matched templates in report"))
warnings += 1

payload = {
"path": str(root),
"checks": [{"status": s, "label": label, "detail": d} for s, label, d in checks],
"failures": failures,
"warnings": warnings,
}
if fix_suggestions:
payload["attack_graph_fix_suggestions"] = fix_suggestions

if json_output or output is not None:
import json
Expand All @@ -136,6 +170,12 @@ def run_doctor(
for status, label, detail in checks:
icon = {"pass": "[green]✓[/green]", "warn": "[yellow]⚠[/yellow]", "fail": "[red]✗[/red]"}[status]
console.print(f"{icon} {escape(label)}: {escape(detail)}")
if fix_suggestions:
console.print("\n[bold]Attack graph suggested fixes[/bold]")
for row in fix_suggestions:
console.print(f" [cyan]{escape(row['template_id'])}[/cyan] — {escape(row['title'])}")
for fix in row.get("recommended_fixes") or []:
console.print(f" • {escape(str(fix.get('description') or fix.get('kind', '')))}")
if root.is_dir():
hints = format_discovery_hints(root)
if hints:
Expand Down
39 changes: 38 additions & 1 deletion src/mcts/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,20 @@ def scan(
help="Disable chain multiplier (chain_factor=1.0); under v2/both the analyzer still runs",
),
] = False,
attack_graph_counterfactuals: Annotated[
bool,
typer.Option(
"--attack-graph-counterfactuals/--no-attack-graph-counterfactuals",
help="Attach counterfactual remediation to attack graph template findings (default on)",
),
] = True,
attack_graph_compress_ui: Annotated[
bool,
typer.Option(
"--attack-graph-compress-ui/--no-attack-graph-compress-ui",
help="Compress matched attack paths in report export for dashboard readability (default on)",
),
] = True,
min_security_score: Annotated[
int | None,
typer.Option(
Expand Down Expand Up @@ -1077,6 +1091,8 @@ def scan(
surface_scoped_analyzers=surface_scoped,
scoring_mode=scoring.lower(),
enable_attack_chains=not no_attack_chains,
attack_graph_enable_counterfactuals=attack_graph_counterfactuals,
attack_graph_compress_for_ui=attack_graph_compress_ui,
min_security_score=min_security_score,
max_absolute_risk=max_absolute_risk,
max_risk_level=max_risk_level.lower() if max_risk_level else None,
Expand Down Expand Up @@ -2089,11 +2105,32 @@ def doctor(
bool,
typer.Option("--json", help="Emit machine-readable JSON"),
] = False,
suggest_fixes: Annotated[
bool,
typer.Option(
"--suggest-fixes",
help="List attack-graph template remediations from a prior scan report",
),
] = False,
report: Annotated[
Path | None,
typer.Option(
"--report",
help="Scan JSON report for --suggest-fixes (e.g. mcts_analysis/scan-report.json)",
),
] = None,
) -> None:
"""Preflight checks before your first scan (no live probes)."""
from mcts.cli.doctor import run_doctor

code = run_doctor(path, deep=deep, json_output=json_output, output=output)
code = run_doctor(
path,
deep=deep,
json_output=json_output,
output=output,
suggest_fixes=suggest_fixes,
report=report,
)
if code:
raise typer.Exit(code=code)

Expand Down
4 changes: 2 additions & 2 deletions src/mcts/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ class ScanConfig(BaseModel):
attack_graph_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
attack_graph_confidence_mode: str = "geometric_mean"
attack_graph_include_overlap_chains: bool = False
attack_graph_enable_counterfactuals: bool = False
attack_graph_compress_for_ui: bool = False
attack_graph_enable_counterfactuals: bool = True
attack_graph_compress_for_ui: bool = True

@classmethod
def _validate_min_evidence_strength(cls, value: str | None) -> str | None:
Expand Down
10 changes: 8 additions & 2 deletions src/mcts/core/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,16 @@ def analyze_server(self, server_info: MCPServerInfo) -> ScanReport:
from mcts.scoring.attack_graph_builder import GraphBuilder
from mcts.scoring.capability_overlap import emit_capability_overlap_findings

attack_graph_model = GraphBuilder(config=self.config).build(server_info, findings)
attack_graph_model = GraphBuilder(config=self.config).build(
server_info,
findings,
inventory=self.inventory,
)
chain_findings = attack_graph_model.to_findings()
findings.extend(chain_findings)
raw_graph = attack_graph_model.to_report_dict()
raw_graph = attack_graph_model.to_report_dict(
compress_for_ui=self.config.attack_graph_compress_for_ui,
)
proven_legacy = {
chain.legacy_finding_id
for chain in attack_graph_model.matched_chains
Expand Down
81 changes: 73 additions & 8 deletions src/mcts/report/assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2068,22 +2068,35 @@
if (!svg) return;
const graph = DATA.attack_graph || {};
const nodes = graph.nodes || [];
const edges = graph.edges || [];
const allEdges = graph.edges || [];
const activeLayer = window.__attackGraphLayer || "all";
const edges =
activeLayer === "all"
? allEdges
: allEdges.filter((e) => (e.layer || "dataflow") === activeLayer);
renderAttackGraphLayers(graph.layers_present || []);
if (!nodes.length) {
svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="#94a3b8">No attack chain data</text>';
renderAttackPaths(graph);
return;
}

const visibleNodeIds = new Set();
edges.forEach((e) => {
visibleNodeIds.add(e.from || e.from_node);
visibleNodeIds.add(e.to || e.to_node);
});
const visibleNodes = nodes.filter((n) => visibleNodeIds.has(n.id));

const width = svg.clientWidth || 800;
const height = 400;
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(width, height) * 0.32;
const positions = {};

nodes.forEach((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2 - Math.PI / 2;
visibleNodes.forEach((n, i) => {
const angle = (i / Math.max(visibleNodes.length, 1)) * Math.PI * 2 - Math.PI / 2;
positions[n.id] = {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
Expand All @@ -2098,15 +2111,17 @@
const from = positions[fromId];
const to = positions[toId];
if (!from || !to) return;
markup += `<line class="graph-edge" x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" marker-end="url(#arrowhead)"/>`;
const edgeClass = e.edge_class ? ` graph-edge ${e.edge_class}` : " graph-edge";
markup += `<line class="${edgeClass.trim()}" x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" marker-end="url(#arrowhead)"/>`;
});

nodes.forEach((n) => {
visibleNodes.forEach((n) => {
const p = positions[n.id];
if (!p) return;
const label = (n.label || n.id || "").length > 14 ? (n.label || n.id || "").slice(0, 12) + "…" : (n.label || n.id || "");
const trust = n.trust ? ` data-trust="${escapeHtml(n.trust)}"` : "";
markup += `
<g class="graph-node" transform="translate(${p.x},${p.y})">
<g class="graph-node"${trust} transform="translate(${p.x},${p.y})">
<circle r="28"/>
<text text-anchor="middle" dy="4">${escapeHtml(label)}</text>
</g>`;
Expand All @@ -2117,6 +2132,33 @@
renderAttackPaths(graph);
}

function renderAttackGraphLayers(layers) {
const toolbar = document.getElementById("attack-graph-layers");
if (!toolbar) return;
const unique = [...new Set(layers.filter(Boolean))];
if (!unique.length) {
toolbar.hidden = true;
toolbar.innerHTML = "";
return;
}
toolbar.hidden = false;
const active = window.__attackGraphLayer || "all";
const buttons = [
`<button type="button" class="graph-layer-btn${active === "all" ? " active" : ""}" data-layer="all">All layers</button>`,
...unique.map(
(layer) =>
`<button type="button" class="graph-layer-btn${active === layer ? " active" : ""}" data-layer="${escapeHtml(layer)}">${escapeHtml(layer.replace(/_/g, " "))}</button>`,
),
];
toolbar.innerHTML = buttons.join("");
toolbar.querySelectorAll(".graph-layer-btn").forEach((btn) => {
btn.addEventListener("click", () => {
window.__attackGraphLayer = btn.dataset.layer || "all";
renderAttackGraph();
});
});
}

function renderAttackPaths(graph) {
const panel = document.getElementById("attack-paths-panel");
const list = document.getElementById("attack-paths-list");
Expand All @@ -2128,7 +2170,12 @@
return;
}
panel.hidden = false;
list.innerHTML = paths
const compression = graph.compression_stats;
const compressionNote =
compression && compression.dropped > 0
? `<p class="muted">Showing ${compression.compressed_count} of ${compression.original_count} matched paths (UI compression).</p>`
: "";
list.innerHTML = compressionNote + paths
.map((path, idx) => {
const template = path.template_id ? `<strong>${escapeHtml(path.template_id)}</strong>` : `Path ${idx + 1}`;
const meta = [
Expand All @@ -2144,7 +2191,25 @@
return `<li>${stepIdx + 1}. ${escapeHtml(msg)}</li>`;
})
.join("");
return `<article class="attack-path-card"><header>${template}${meta ? ` <span class="muted">(${escapeHtml(meta)})</span>` : ""}</header>${steps ? `<ol>${steps}</ol>` : ""}</article>`;
const fixes = (path.recommended_fixes || [])
.map((fix) => {
const label = fix.description || fix.kind || "";
return `<li>${escapeHtml(label)}</li>`;
})
.join("");
const fixesBlock = fixes
? `<div class="attack-path-fixes"><strong>Suggested fixes</strong><ul>${fixes}</ul></div>`
: "";
const counterfactual = path.counterfactual_remediation;
const cfActions = counterfactual && counterfactual.actions
? counterfactual.actions
.map((action) => `<li>${escapeHtml(action.action || action)}</li>`)
.join("")
: "";
const cfBlock = cfActions
? `<div class="attack-path-fixes"><strong>Counterfactual</strong><ul>${cfActions}</ul></div>`
: "";
return `<article class="attack-path-card"><header>${template}${meta ? ` <span class="muted">(${escapeHtml(meta)})</span>` : ""}</header>${steps ? `<ol>${steps}</ol>` : ""}${fixesBlock}${cfBlock}</article>`;
})
.join("");
}
Expand Down
47 changes: 47 additions & 0 deletions src/mcts/report/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2909,6 +2909,53 @@ body.modal-open {
marker-end: url(#arrowhead);
}

.graph-edge.policy {
stroke: rgba(148, 163, 184, 0.75);
stroke-dasharray: 5 4;
}

.graph-edge.inferred {
stroke: rgba(148, 163, 184, 0.45);
}

.graph-edge.runtime {
stroke: rgba(34, 197, 94, 0.7);
}

.graph-layer-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}

.graph-layer-btn {
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
}

.graph-layer-btn.active {
border-color: var(--accent);
color: var(--text);
background: rgba(59, 130, 246, 0.12);
}

.attack-path-fixes {
margin-top: 8px;
font-size: 12px;
color: var(--muted);
}

.attack-path-fixes ul {
margin: 4px 0 0;
padding-left: 18px;
}

/* Analyzers */
.analyzer-grid {
display: grid;
Expand Down
3 changes: 2 additions & 1 deletion src/mcts/report/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,8 @@ <h2 class="section-title">Attack Paths</h2>
<p class="section-subtitle">How tools could be chained together for a multi-step attack (read → exfiltrate, etc.).</p>
<div class="card card-interactive" data-card-action="goto:findings" aria-label="View findings related to attack paths">
<span class="card-cta">View related issues →</span>
<p style="color:var(--muted);margin:0 0 16px">Each arrow shows a possible step between tools.</p>
<p style="color:var(--muted);margin:0 0 16px">Each arrow shows a possible step between tools. Policy edges are dashed; inferred edges are muted.</p>
<div id="attack-graph-layers" class="graph-layer-toolbar" role="toolbar" aria-label="Filter graph by layer" hidden></div>
<div class="graph-wrap">
<svg id="attack-graph" role="img" aria-label="Attack chain graph"></svg>
</div>
Expand Down
Loading
Loading