Skip to content

Commit 9b1239d

Browse files
committed
feat(governance): auxiliary gate parity and MCP trust tooling
Route vet/fuzz/inventory/pentest through collect_findings_gate_violations, enrich explain_finding and compare_baselines with display-aware fields, add embedding_secrets semantic skip row, and run validate_trust_layer in CI.
1 parent e9a3875 commit 9b1239d

10 files changed

Lines changed: 245 additions & 21 deletions

File tree

.github/workflows/test-gate.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
- run: uv run python scripts/run_behavioral_regression.py --strict --json
4747
- run: uv run python scripts/run_behavioral_eval.py --json > behavioral-eval.json
4848
- run: uv run pytest -m integration tests/integration/
49+
- run: uv run python scripts/validate_trust_layer.py
50+
if: matrix.python-version == '3.12'
4951
- run: |
5052
uv run python - <<'PY'
5153
from mcts.testing.regression_harness import REGRESSION_THRESHOLD, REGRESSION_TECHNIQUES, evaluate_technique

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- **Pentest warn recommendations** — remediation text matches display severity (no false “remediate critical” on overlap)
4040
- **Readiness warn scoring**`readiness_score` and `production_ready` use display severity when trust ≠ off
4141
- **Acceptance script lint**`scripts/validate_trust_layer.py` passes `ruff check .` (CI blocker)
42+
- **Auxiliary gate parity** — vet/fuzz/inventory/readiness/pentest use `collect_findings_gate_violations()`
43+
- **MCP explain_finding** — trust fields, facts, and interpretation in tool output
44+
- **compare_baselines** — display-aware critical/high counts when trust summaries present
45+
- **embedding_secrets** — skip row when semantic model unavailable
4246
- **Validator `path_status`** — stale `evidence.path_status=proven` no longer bypasses graph checks
4347
- **Compliance coverage kind** — compliance meta-findings tagged `finding_kind=coverage` (excluded from security priority/bronze gates)
4448
- **`require_auth_env_for_sensitive`** — policy gate fails when sensitive analyzers enabled without API env vars

docs/reporting/findings-trust-phase0.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ Items 1–8 from the June 2026 deep audits are **fixed** in-tree (see table abov
296296

297297
### Gate scope (auxiliary commands)
298298

299-
Full YAML + scan gate evaluation (`collect_gate_violations`) applies to **`mcts scan`**, **REST `POST /scan`**, **`mcts scan --machine-wide`**, and **`mcts inventory --scan-all`**. Other auxiliary paths (`inventory` default, `vet`, `fuzz`, `readiness`, `pentest`) merge policy for trust mode and thresholds but exit on **severity heuristics** unless documented otherwise.
299+
Full YAML + scan gate evaluation (`collect_gate_violations`) applies to **`mcts scan`**, **REST `POST /scan`**, **`mcts scan --machine-wide`**, and **`mcts inventory --scan-all`**. Auxiliary CLIs (`inventory` default, `vet`, `fuzz`, `readiness`, `pentest`) call **`collect_findings_gate_violations()`** for policy thresholds (`max_critical`, priority gates, etc.) plus the legacy critical/high heuristic when findings remain severe.
300300

301301
### Policy trust mode on auxiliary CLIs
302302

src/mcts/analyzers/embedding_secrets.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from mcts.analyzers.base import BaseAnalyzer
1111
from mcts.analyzers.data_leakage import SECRET_PATTERNS
12-
from mcts.analyzers.finding_facts import build_analyzer_finding
12+
from mcts.analyzers.finding_facts import build_analyzer_finding, build_skip_finding
1313
from mcts.mcp.models import MCPServerInfo
1414
from mcts.reporting.models import Finding, Severity, SourceLocation
1515

@@ -58,13 +58,29 @@ def __init__(self, semantic_secrets: bool = False, semantic_threshold: float = 0
5858

5959
def analyze(self, server: MCPServerInfo) -> list[Finding]:
6060
findings: list[Finding] = []
61+
if self.semantic_secrets:
62+
_load_embedding_model()
6163
for tool in server.tools:
6264
corpus = _tool_corpus(tool)
6365
if _regex_credential_hit(corpus):
6466
findings.append(_finding(tool, "regex_credential", 0.9))
6567
continue
6668
if self.semantic_secrets and _semantic_credential_hit(corpus, self.semantic_threshold):
6769
findings.append(_finding(tool, "semantic_credential", 0.8))
70+
if self.semantic_secrets and _EMBEDDING_STATE.unavailable:
71+
findings.append(
72+
build_skip_finding(
73+
finding_id="embedding-secrets-semantic-skipped",
74+
analyzer="embedding_secrets",
75+
title="Semantic credential detection skipped",
76+
description=(
77+
"Semantic embedding model unavailable; only regex and phrase fallback ran."
78+
),
79+
recommendation=(
80+
"Install sentence-transformers and model weights, or disable semantic_secrets."
81+
),
82+
)
83+
)
6884
return findings
6985

7086

src/mcts/cli/main.py

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ def _level_exceeds(actual: str, maximum: str) -> bool:
212212
def _check_gates(report, config: ScanConfig) -> None:
213213
from mcts.governance.gate_violations import collect_gate_violations
214214

215-
violations = collect_gate_violations(report, config)
215+
_exit_on_gate_violations(collect_gate_violations(report, config), report, config)
216+
217+
218+
def _exit_on_gate_violations(violations: list[str], report, config: ScanConfig) -> None:
216219
if not violations:
217220
return
218221

@@ -248,6 +251,54 @@ def _check_gates(report, config: ScanConfig) -> None:
248251
raise typer.Exit(code=1)
249252

250253

254+
def _check_finding_policy_gates(
255+
findings: list,
256+
config: ScanConfig,
257+
*,
258+
target: str | None = None,
259+
scan_scope: str = "repository",
260+
) -> None:
261+
"""YAML/CLI policy gates for auxiliary finding lists (no severity heuristic)."""
262+
from mcts.governance.gate_violations import build_gate_scan_report, collect_findings_gate_violations
263+
264+
violations = collect_findings_gate_violations(
265+
findings,
266+
config,
267+
target=target,
268+
scan_scope=scan_scope,
269+
)
270+
if violations:
271+
gate_report = build_gate_scan_report(
272+
findings,
273+
config,
274+
target=target,
275+
scan_scope=scan_scope,
276+
)
277+
_exit_on_gate_violations(violations, gate_report, config)
278+
279+
280+
def _check_auxiliary_finding_gates(
281+
findings: list,
282+
config: ScanConfig,
283+
*,
284+
target: str | None = None,
285+
scan_scope: str = "repository",
286+
) -> None:
287+
"""Policy gates plus legacy critical/high heuristic for security-oriented CLIs."""
288+
from mcts.reporting.trust_apply import finding_severity_label
289+
290+
_check_finding_policy_gates(
291+
findings,
292+
config,
293+
target=target,
294+
scan_scope=scan_scope,
295+
)
296+
if findings and any(
297+
finding_severity_label(finding, config) in ("critical", "high") for finding in findings
298+
):
299+
raise typer.Exit(code=1)
300+
301+
251302
@app.callback()
252303
def main(
253304
version: Annotated[
@@ -1150,7 +1201,6 @@ def inventory(
11501201
scan_all_has_high_severity,
11511202
write_inventory_scan_all,
11521203
)
1153-
from mcts.reporting.display import effective_severity
11541204
from mcts.reporting.trust_apply import apply_config_trust_layer
11551205
from mcts.taxonomy.mapper import enrich_findings
11561206

@@ -1252,12 +1302,12 @@ def inventory(
12521302
ReportRenderer(resolved_theme, console=console).render_saved_notice(str(output_path))
12531303

12541304
combined = shadow_findings + skill_findings + toxic_findings
1255-
if combined and any(
1256-
(effective_severity(f) if inv_config.findings_trust_mode != "off" else f.severity).value
1257-
in ("critical", "high")
1258-
for f in combined
1259-
):
1260-
raise typer.Exit(code=1)
1305+
_check_auxiliary_finding_gates(
1306+
combined,
1307+
inv_config,
1308+
target=str(inv_config.target),
1309+
scan_scope="inventory",
1310+
)
12611311

12621312

12631313
@app.command()
@@ -1294,8 +1344,8 @@ def vet(
12941344
import json
12951345

12961346
from mcts.core.config import ScanConfig
1297-
from mcts.reporting.trust_apply import finding_severity_label, merge_scan_config_defaults
1298-
from mcts.reporting.vet_trust import apply_trust_to_vet_report, vet_severity_label
1347+
from mcts.reporting.trust_apply import merge_scan_config_defaults
1348+
from mcts.reporting.vet_trust import apply_trust_to_vet_report, vet_finding_to_finding, vet_severity_label
12991349
from mcts.vet import run_vet
13001350

13011351
try:
@@ -1334,8 +1384,13 @@ def vet(
13341384
if not json_output:
13351385
console.print(f"[green]Saved[/green] {output_path}")
13361386

1337-
if any(finding_severity_label(finding, config) in ("critical", "high") for finding in report.findings):
1338-
raise typer.Exit(code=1)
1387+
gate_findings = [vet_finding_to_finding(finding) for finding in report.findings]
1388+
_check_auxiliary_finding_gates(
1389+
gate_findings,
1390+
config,
1391+
target=package,
1392+
scan_scope="vet",
1393+
)
13391394

13401395

13411396
@app.command()
@@ -1637,8 +1692,12 @@ def fuzz(
16371692
output_path.write_text(json.dumps(payload, indent=2))
16381693
ReportRenderer(resolved_theme, console=console).render_saved_notice(str(output_path))
16391694

1640-
if any(finding_severity_label(finding, fuzz_config) in ("critical", "high") for finding in findings):
1641-
raise typer.Exit(code=1)
1695+
_check_auxiliary_finding_gates(
1696+
findings,
1697+
fuzz_config,
1698+
target=target_label,
1699+
scan_scope="live" if (url or command) else "repository",
1700+
)
16421701

16431702

16441703
def _parse_headers(header: list[str] | None) -> dict[str, str]:
@@ -1724,6 +1783,13 @@ def readiness(
17241783
if report.tools_checked == 0:
17251784
raise typer.Exit(code=1)
17261785

1786+
_check_finding_policy_gates(
1787+
report.findings,
1788+
config,
1789+
target=str(target),
1790+
scan_scope="readiness",
1791+
)
1792+
17271793

17281794
@app.command(name="serve")
17291795
def serve_api(
@@ -2205,6 +2271,18 @@ def _execute() -> object:
22052271
console.print(f" • {item}")
22062272
console.print(f"\n[green]Saved[/green] {output_path}")
22072273

2274+
if report.static_report:
2275+
from mcts.reporting.models import Finding, ScanReport
2276+
2277+
static_scan = ScanReport.model_validate(report.static_report)
2278+
fuzz_rows = [Finding.model_validate(row) for row in report.fuzz_findings]
2279+
_check_auxiliary_finding_gates(
2280+
static_scan.findings + fuzz_rows,
2281+
config,
2282+
target=str(target),
2283+
scan_scope=static_scan.scan_scope,
2284+
)
2285+
22082286
if report.verdict in {"critical", "high"}:
22092287
raise typer.Exit(code=1)
22102288

src/mcts/governance/gate_violations.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from __future__ import annotations
44

5+
from datetime import UTC, datetime
6+
57
from mcts.core.config import ScanConfig
68
from mcts.governance.auth_env import evaluate_auth_env_violations
79
from mcts.governance.policy import evaluate_policy, load_policy
810
from mcts.governance.scan_gates import evaluate_scan_gate_violations
9-
from mcts.reporting.models import ScanReport
11+
from mcts.mcp.models import MCPServerInfo
12+
from mcts.reporting.models import Finding, RiskScore, ScanReport, ScanSummary, ScoreBasis
1013

1114

1215
def _policy_server_ids(report: ScanReport) -> list[str]:
@@ -35,3 +38,58 @@ def collect_gate_violations(report: ScanReport, config: ScanConfig) -> list[str]
3538
return violations
3639
violations.extend(evaluate_policy(policy=policy, servers=_policy_server_ids(report)))
3740
return violations
41+
42+
43+
def build_gate_scan_report(
44+
findings: list[Finding],
45+
config: ScanConfig,
46+
*,
47+
target: str | None = None,
48+
scan_scope: str = "repository",
49+
) -> ScanReport:
50+
"""Minimal ScanReport for gate evaluation on auxiliary finding lists."""
51+
report_target = target or str(config.target)
52+
summary = ScanSummary.from_findings(findings)
53+
display_summary = (
54+
ScanSummary.from_display(findings, security_only=True)
55+
if config.findings_trust_mode != "off"
56+
else None
57+
)
58+
basis = ScoreBasis(
59+
critical=summary.critical,
60+
high=summary.high,
61+
medium=summary.medium,
62+
low=summary.low,
63+
scorable_total=summary.total,
64+
excluded_non_scorable=max(0, len(findings) - summary.total),
65+
)
66+
score = RiskScore(overall=100, risk_index=0, raw_risk=0, penalty=0, basis=basis)
67+
return ScanReport(
68+
version="0.0.0",
69+
target=report_target,
70+
scanned_at=datetime.now(UTC),
71+
server=MCPServerInfo(name=report_target),
72+
findings=findings,
73+
summary=summary,
74+
display_summary=display_summary,
75+
findings_trust_mode=config.findings_trust_mode,
76+
score=score,
77+
scan_scope=scan_scope,
78+
)
79+
80+
81+
def collect_findings_gate_violations(
82+
findings: list[Finding],
83+
config: ScanConfig,
84+
*,
85+
target: str | None = None,
86+
scan_scope: str = "repository",
87+
) -> list[str]:
88+
"""Policy/CLI gates for vet, fuzz, inventory, and other non-Scanner entry points."""
89+
report = build_gate_scan_report(
90+
findings,
91+
config,
92+
target=target,
93+
scan_scope=scan_scope,
94+
)
95+
return collect_gate_violations(report, config)

src/mcts/mcp_server/server.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,29 @@ def explain_finding(finding_id: str, report_json: str) -> str:
5757
"id": match.get("id"),
5858
"title": match.get("title"),
5959
"severity": match.get("severity"),
60+
"display_severity": match.get("display_severity"),
61+
"impact": match.get("impact"),
62+
"evidence_strength": match.get("evidence_strength"),
63+
"evidence_type": match.get("evidence_type"),
64+
"finding_type": match.get("finding_type"),
65+
"finding_kind": match.get("finding_kind"),
66+
"priority_score": match.get("priority_score"),
67+
"chain_level": match.get("chain_level"),
68+
"rule_stability": match.get("rule_stability"),
6069
"analyzer": match.get("analyzer"),
6170
"technique_id": match.get("technique_id"),
6271
"description": match.get("description"),
6372
"recommendation": match.get("recommendation"),
64-
"evidence": match.get("evidence") or {},
6573
"tool": match.get("tool"),
6674
}
75+
evidence = match.get("evidence") or {}
76+
explanation["confidence_factors"] = evidence.get("confidence_factors")
77+
explanation["false_positive_conditions"] = evidence.get("false_positive_conditions")
78+
explanation["counterfactual_remediation"] = evidence.get("counterfactual_remediation")
79+
explanation["facts"] = evidence.get("facts")
80+
explanation["interpretation"] = evidence.get("interpretation")
81+
explanation["runtime_validation"] = evidence.get("runtime_validation")
82+
explanation["evidence"] = evidence
6783
return json.dumps(explanation, indent=2)
6884

6985

@@ -91,6 +107,9 @@ def compare_baselines(baseline_report_json: str, current_report_json: str) -> st
91107
delta["chain_meta_note"] = (
92108
"Finding deltas may include attack_chains meta-rows excluded from v2 absolute_risk."
93109
)
110+
display_crit_delta = (current.get("display_critical") or 0) - (baseline.get("display_critical") or 0)
111+
if display_crit_delta and display_crit_delta != chain_delta:
112+
delta["display_critical_delta"] = display_crit_delta
94113
return json.dumps(delta, indent=2)
95114

96115

@@ -114,16 +133,38 @@ def create_server():
114133
return app
115134

116135

136+
def _severity_counts(payload: dict[str, Any]) -> dict[str, int]:
137+
trust_mode = str(payload.get("findings_trust_mode") or "off")
138+
display = payload.get("display_summary") or {}
139+
template = payload.get("summary") or {}
140+
use_display = trust_mode == "enforce" or (
141+
trust_mode == "warn" and display.get("critical") is not None
142+
)
143+
active = display if use_display and display else template
144+
return {
145+
"critical": int(active.get("critical") or 0),
146+
"high": int(active.get("high") or 0),
147+
}
148+
149+
117150
def _report_summary(payload: dict[str, Any]) -> dict[str, Any]:
118151
score = payload.get("score") or {}
119152
score_v2 = payload.get("score_v2") or {}
120153
findings = payload.get("findings") or []
154+
template = payload.get("summary") or {}
155+
display = payload.get("display_summary") or {}
156+
counts = _severity_counts(payload)
121157
summary: dict[str, Any] = {
122158
"overall_score": int(score.get("overall") or 0),
123159
"finding_count": len(findings),
124160
"finding_ids": sorted(str(row.get("id")) for row in findings if row.get("id")),
125-
"critical": int((payload.get("summary") or {}).get("critical") or 0),
126-
"high": int((payload.get("summary") or {}).get("high") or 0),
161+
"critical": counts["critical"],
162+
"high": counts["high"],
163+
"template_critical": int(template.get("critical") or 0),
164+
"template_high": int(template.get("high") or 0),
165+
"display_critical": int(display.get("critical") or 0) if display else None,
166+
"display_high": int(display.get("high") or 0) if display else None,
167+
"findings_trust_mode": payload.get("findings_trust_mode") or "off",
127168
"scoring_version": payload.get("scoring_version") or "legacy",
128169
}
129170
if score_v2:

0 commit comments

Comments
 (0)