Skip to content

Commit 10c6490

Browse files
authored
Merge pull request #133 from NuGuardAI/ranjan/redteam-v2
feat: redteam v2, behavior/redteam public APIs, remediation output, v0.8.5
2 parents 4526b28 + 8bedd1c commit 10c6490

9 files changed

Lines changed: 255 additions & 7 deletions

File tree

npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nuguardai/nuguard",
3-
"version": "0.8.4",
3+
"version": "0.8.5",
44
"description": "AI Application Security — SBOM generation, static analysis, behavioral validation, and adversarial red-team testing for AI agents and LLM-powered applications.",
55
"keywords": [
66
"mcp",

nuguard/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""NuGuard AI Security CLI — open-source AI penetration testing platform."""
22

3-
__version__ = "0.8.4"
3+
__version__ = "0.8.5"

nuguard/behavior/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ class BehaviorRunResult(BaseModel):
334334
coverage_mapping_diagnostics: dict[str, Any] = Field(default_factory=dict)
335335
effective_endpoint: str = ""
336336
target_endpoint_source: str = "config"
337+
remediation_plan: list[RemediationArtefact] = Field(default_factory=list)
338+
"""Concrete, SBOM-node-specific remediation artefacts for ``findings``.
339+
340+
Populated by :func:`nuguard.behavior.public_api.run_behavior_scenarios`
341+
(best-effort) via ``RemediationSynthesizer``. Empty when called directly
342+
through :class:`~nuguard.behavior.runner.BehaviorRunner`, which does not
343+
synthesize remediation itself.
344+
"""
337345

338346

339347
class BehaviorAnalysisResult(BaseModel):

nuguard/behavior/public_api.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
BehaviorAnalysisResult,
2323
BehaviorRunResult,
2424
BehaviorScenario,
25+
RemediationArtefact,
2526
)
2627
from nuguard.behavior.runner import BehaviorRunner
2728
from nuguard.common.discovery import DiscoveredProfile
@@ -79,6 +80,33 @@ class BehaviorRunRequest(BaseModel):
7980
pre_scan_profile: DiscoveredProfile | None = None
8081

8182

83+
async def _synthesize_behavior_remediation_plan(
84+
findings: list[dict],
85+
*,
86+
sbom: "AiSbomDocument | None",
87+
policy: "CognitivePolicy | None",
88+
llm_client: "LLMClient | None",
89+
) -> list[RemediationArtefact]:
90+
"""Best-effort structured remediation for a plain list of finding dicts.
91+
92+
Mirrors the CLI's ``_build_redteam_remediation_plan`` (which reuses this
93+
same synthesizer for redteam findings): remediation synthesis enriches
94+
the result but must never fail the run, so exceptions are logged and
95+
swallowed. Returns ``[]`` when there is no SBOM or no findings to
96+
synthesize against.
97+
"""
98+
if sbom is None or not findings:
99+
return []
100+
try:
101+
from nuguard.behavior.remediation import RemediationSynthesizer # noqa: PLC0415
102+
103+
synthesizer = RemediationSynthesizer(sbom=sbom, policy=policy, llm_client=llm_client)
104+
return await synthesizer.synthesize_findings_async(findings)
105+
except Exception as exc: # noqa: BLE001
106+
_log.warning("run_behavior_scenarios: remediation synthesis failed: %s", exc)
107+
return []
108+
109+
82110
async def run_behavior_scenarios(
83111
request: BehaviorRunRequest,
84112
*,
@@ -90,7 +118,12 @@ async def run_behavior_scenarios(
90118
) -> BehaviorRunResult:
91119
"""Run a list of behavior scenarios from a JSON-safe request.
92120
93-
Thin wrapper around ``BehaviorRunner(...).run(...)``.
121+
Thin wrapper around ``BehaviorRunner(...).run(...)``. Additionally
122+
synthesizes ``result.remediation_plan`` — concrete, SBOM-node-specific
123+
remediation artefacts — from the run's findings, the same way
124+
:meth:`~nuguard.behavior.analyzer.BehaviorAnalyzer.analyze` does for the
125+
full static+dynamic pipeline. This is best-effort enrichment: it never
126+
raises, and simply leaves ``remediation_plan`` empty on failure.
94127
"""
95128
_log.debug("run_behavior_scenarios: %d scenario(s)", len(request.scenarios))
96129
runner = BehaviorRunner(
@@ -101,7 +134,11 @@ async def run_behavior_scenarios(
101134
llm_client=llm_client,
102135
judge_cache=judge_cache,
103136
)
104-
return await runner.run(request.scenarios, pre_scan_profile=request.pre_scan_profile)
137+
result = await runner.run(request.scenarios, pre_scan_profile=request.pre_scan_profile)
138+
result.remediation_plan = await _synthesize_behavior_remediation_plan(
139+
result.findings, sbom=sbom, policy=policy, llm_client=llm_client
140+
)
141+
return result
105142

106143

107144
async def discover_behavior_profile(

nuguard/redteam/public_api.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from pydantic import BaseModel, Field
2323

24+
from nuguard.behavior.models import RemediationArtefact
2425
from nuguard.common.auth import AuthConfig
2526
from nuguard.common.logging import get_logger
2627
from nuguard.config import RedteamFindingTriggers
@@ -124,6 +125,54 @@ class RedteamRunResult(BaseModel):
124125
resolved_chat_path_source: str
125126
catalog_coverage: dict[str, Any] | None = None
126127
coverage_tracker: dict[str, Any] | None = None
128+
remediation_plan: list[RemediationArtefact] = Field(default_factory=list)
129+
"""Concrete, SBOM-node-specific remediation artefacts for ``findings``.
130+
131+
Synthesized best-effort from ``findings`` via the same
132+
``RemediationSynthesizer`` the CLI uses to build its redteam report's
133+
remediation plan (``nuguard.cli.commands.redteam._build_redteam_remediation_plan``),
134+
with contextual LLM patch text when ``eval_llm`` is supplied to
135+
:func:`run_redteam`. Empty when synthesis fails or no SBOM is available.
136+
"""
137+
138+
139+
async def _build_remediation_plan(
140+
findings: list[Finding],
141+
*,
142+
sbom: "AiSbomDocument | None",
143+
policy: "CognitivePolicy | None",
144+
llm_client: "LLMClient | None",
145+
) -> list[RemediationArtefact]:
146+
"""Synthesize per-SBOM-node remediation artefacts from redteam findings.
147+
148+
Async counterpart to the CLI's ``_build_redteam_remediation_plan``
149+
(``nuguard/cli/commands/redteam.py``) — uses ``synthesize_findings_async``
150+
instead of the sync ``synthesize_findings`` since this runs inside
151+
``run_redteam``'s already-running event loop, so LLM patch calls need to
152+
be awaited directly rather than silently skipped by the sync shim.
153+
Best-effort: returns ``[]`` on missing SBOM, no findings, or any failure.
154+
"""
155+
if sbom is None or not findings:
156+
return []
157+
try:
158+
from nuguard.behavior.remediation import RemediationSynthesizer # noqa: PLC0415
159+
160+
synthesizer = RemediationSynthesizer(sbom=sbom, policy=policy, llm_client=llm_client)
161+
finding_dicts = [
162+
{
163+
"finding_id": f.finding_id,
164+
"title": f.title,
165+
"description": f.description or "",
166+
"affected_component": f.affected_component or "unknown",
167+
"severity": f.severity.value if hasattr(f.severity, "value") else str(f.severity),
168+
"goal_type": f.goal_type or "",
169+
}
170+
for f in findings
171+
]
172+
return await synthesizer.synthesize_findings_async(finding_dicts)
173+
except Exception as exc: # noqa: BLE001
174+
_log.warning("run_redteam: remediation synthesis failed — skipping plan: %s", exc)
175+
return []
127176

128177

129178
def _catalog_coverage_to_dict(report: "CoverageReport | None") -> dict[str, Any] | None:
@@ -235,6 +284,10 @@ async def run_redteam(
235284
)
236285
]
237286

287+
remediation_plan = await _build_remediation_plan(
288+
findings, sbom=sbom, policy=policy, llm_client=eval_llm
289+
)
290+
238291
coverage_tracker = getattr(orchestrator, "_coverage_tracker", None)
239292
return RedteamRunResult(
240293
findings=findings,
@@ -253,4 +306,5 @@ async def run_redteam(
253306
resolved_chat_path_source=orchestrator.resolved_chat_path_source,
254307
catalog_coverage=_catalog_coverage_to_dict(getattr(orchestrator, "catalog_coverage", None)),
255308
coverage_tracker=coverage_tracker.to_dict() if coverage_tracker is not None else None,
309+
remediation_plan=remediation_plan,
256310
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "nuguard"
3-
version = "0.8.4"
3+
version = "0.8.5"
44
description = "AI application security — SBOM generation, vulnerability scanning, behavioral validation, and adversarial red-teaming for AI Agents"
55
readme = "README.md"
66
license = { text = "Apache-2.0" }

smithery.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Smithery manifest — Claude marketplace MCP server
22
# https://smithery.ai/docs/config
33
name: nuguard
4-
version: "0.8.4"
4+
version: "0.8.5"
55
description: |
66
AI Application Security — generate an AI Bill of Materials (AI-SBOM), run static
77
analysis, behavioral validation, and adversarial red-team testing for AI agents

tests/behavior/test_public_api.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ async def test_analyze_behavior_propagates_exceptions():
130130

131131
@pytest.mark.asyncio
132132
async def test_run_behavior_scenarios_constructs_runner_and_forwards_args():
133-
sentinel_result = MagicMock(spec=BehaviorRunResult)
133+
# A real (empty-findings) result rather than MagicMock(spec=...): the
134+
# wrapper now reads result.findings and sets result.remediation_plan for
135+
# remediation synthesis, and pydantic model classes don't expose field
136+
# names via dir(), so a spec'd mock can't stand in for those attributes.
137+
sentinel_result = BehaviorRunResult(run_id="run1")
134138
config = BehaviorConfig(target="http://localhost:9999")
135139
scenarios = [_scenario("a")]
136140
profile = DiscoveredProfile(customer_name="Bob", source="config")
@@ -161,6 +165,74 @@ async def test_run_behavior_scenarios_constructs_runner_and_forwards_args():
161165
assert [s.name for s in called_args[0]] == ["a"]
162166
assert called_kwargs["pre_scan_profile"] is profile
163167
assert result is sentinel_result
168+
assert result.remediation_plan == []
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_run_behavior_scenarios_populates_remediation_plan():
173+
"""Findings + a real SBOM should produce structured RemediationArtefact objects."""
174+
from nuguard.behavior.models import RemediationArtefact, RemediationArtefactType
175+
176+
finding = {
177+
"finding_id": "BA-004-1",
178+
"title": "PII disclosed via datastore",
179+
"description": "Agent leaked account_number in a response.",
180+
"affected_component": "SupportAgent",
181+
"severity": "high",
182+
}
183+
sentinel_result = BehaviorRunResult(run_id="run1", findings=[finding])
184+
config = BehaviorConfig(target="http://localhost:9999")
185+
186+
fake_artefact = RemediationArtefact(
187+
finding_ids=["BA-004-1"],
188+
component="SupportAgent",
189+
component_type="AGENT",
190+
artefact_type=RemediationArtefactType.OUTPUT_GUARDRAIL,
191+
priority="high",
192+
rationale="Sensitive fields must not appear in agent responses.",
193+
)
194+
195+
from types import SimpleNamespace
196+
197+
empty_sbom = SimpleNamespace(nodes=[], edges=[])
198+
199+
with (
200+
patch("nuguard.behavior.public_api.BehaviorRunner") as mock_runner_cls,
201+
patch("nuguard.behavior.remediation.RemediationSynthesizer.synthesize_findings_async") as mock_synth,
202+
):
203+
mock_runner_cls.return_value.run = AsyncMock(return_value=sentinel_result)
204+
mock_synth.return_value = [fake_artefact]
205+
206+
request = BehaviorRunRequest(config=config, scenarios=[_scenario("a")])
207+
result = await run_behavior_scenarios(request, sbom=empty_sbom)
208+
209+
mock_synth.assert_awaited_once_with([finding])
210+
assert result.remediation_plan == [fake_artefact]
211+
212+
213+
@pytest.mark.asyncio
214+
async def test_run_behavior_scenarios_remediation_synthesis_failure_is_swallowed():
215+
"""Remediation synthesis is best-effort — a failure must not fail the run."""
216+
sentinel_result = BehaviorRunResult(
217+
run_id="run1",
218+
findings=[{"finding_id": "f1", "title": "t", "description": "d", "affected_component": "c", "severity": "low"}],
219+
)
220+
config = BehaviorConfig(target="http://localhost:9999")
221+
222+
from types import SimpleNamespace
223+
224+
with (
225+
patch("nuguard.behavior.public_api.BehaviorRunner") as mock_runner_cls,
226+
patch(
227+
"nuguard.behavior.remediation.RemediationSynthesizer.synthesize_findings_async",
228+
side_effect=RuntimeError("boom"),
229+
),
230+
):
231+
mock_runner_cls.return_value.run = AsyncMock(return_value=sentinel_result)
232+
request = BehaviorRunRequest(config=config, scenarios=[_scenario("a")])
233+
result = await run_behavior_scenarios(request, sbom=SimpleNamespace(nodes=[], edges=[]))
234+
235+
assert result.remediation_plan == []
164236

165237

166238
# ---------------------------------------------------------------------------

tests/redteam/test_public_api.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,80 @@ async def test_run_redteam_propagates_exceptions():
244244
request = RedteamRunRequest(target_url="http://target")
245245
with pytest.raises(RuntimeError, match="target unreachable"):
246246
await run_redteam(request, sbom=MagicMock())
247+
248+
249+
# ---------------------------------------------------------------------------
250+
# remediation_plan — structured, machine-actionable remediation
251+
# ---------------------------------------------------------------------------
252+
253+
254+
@pytest.mark.asyncio
255+
async def test_run_redteam_populates_remediation_plan():
256+
"""Findings + a real SBOM should produce structured RemediationArtefact objects,
257+
via the same RemediationSynthesizer the CLI uses for its report's remediation plan."""
258+
from types import SimpleNamespace
259+
260+
from nuguard.behavior.models import RemediationArtefact, RemediationArtefactType
261+
262+
findings = [_finding("data_exfiltration")]
263+
mock_instance = _make_mock_orchestrator(findings)
264+
fake_artefact = RemediationArtefact(
265+
finding_ids=["f1"],
266+
component="unknown",
267+
component_type="AGENT",
268+
artefact_type=RemediationArtefactType.OUTPUT_GUARDRAIL,
269+
priority="high",
270+
rationale="d",
271+
)
272+
273+
with (
274+
patch("nuguard.redteam.public_api.RedteamOrchestrator") as mock_cls,
275+
patch(
276+
"nuguard.behavior.remediation.RemediationSynthesizer.synthesize_findings_async"
277+
) as mock_synth,
278+
):
279+
mock_cls.return_value = mock_instance
280+
mock_synth.return_value = [fake_artefact]
281+
request = RedteamRunRequest(target_url="http://target")
282+
result = await run_redteam(request, sbom=SimpleNamespace(nodes=[], edges=[]))
283+
284+
mock_synth.assert_awaited_once()
285+
(finding_dicts,), _ = mock_synth.await_args
286+
assert finding_dicts[0]["finding_id"] == "f1"
287+
assert finding_dicts[0]["goal_type"] == "data_exfiltration"
288+
assert result.remediation_plan == [fake_artefact]
289+
290+
291+
@pytest.mark.asyncio
292+
async def test_run_redteam_remediation_plan_empty_without_findings():
293+
mock_instance = _make_mock_orchestrator([])
294+
295+
with patch("nuguard.redteam.public_api.RedteamOrchestrator") as mock_cls:
296+
mock_cls.return_value = mock_instance
297+
request = RedteamRunRequest(target_url="http://target")
298+
result = await run_redteam(request, sbom=MagicMock())
299+
300+
assert result.remediation_plan == []
301+
302+
303+
@pytest.mark.asyncio
304+
async def test_run_redteam_remediation_synthesis_failure_is_swallowed():
305+
"""Remediation synthesis is best-effort — a failure must not fail the run."""
306+
findings = [_finding("data_exfiltration")]
307+
mock_instance = _make_mock_orchestrator(findings)
308+
309+
with (
310+
patch("nuguard.redteam.public_api.RedteamOrchestrator") as mock_cls,
311+
patch(
312+
"nuguard.behavior.remediation.RemediationSynthesizer.synthesize_findings_async",
313+
side_effect=RuntimeError("boom"),
314+
),
315+
):
316+
mock_cls.return_value = mock_instance
317+
request = RedteamRunRequest(target_url="http://target")
318+
from types import SimpleNamespace
319+
320+
result = await run_redteam(request, sbom=SimpleNamespace(nodes=[], edges=[]))
321+
322+
assert result.remediation_plan == []
323+
assert len(result.findings) == 1

0 commit comments

Comments
 (0)