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
3 changes: 2 additions & 1 deletion .devcontainer/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ python-telegram-bot[rate-limiter]
protobuf==5.28.3
ngrok
paramiko
pymisp
pymisp
sqlfluff
40 changes: 39 additions & 1 deletion .devcontainer/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def __init__(self, debounce_seconds=1.0):
self.last_restart = 0
self.debounce_seconds = debounce_seconds
self.restart_pending = False
self.crash_timestamps = []
self.crash_window_seconds = 300.0
self.max_crashes_in_window = 5
self.base_crash_restart_delay = 2.0
self.max_crash_restart_delay = 30.0

def start_process(self):
"""Start or restart the edge node process."""
Expand Down Expand Up @@ -79,6 +84,21 @@ def _should_restart(self):
"""Check if enough time has passed since last restart."""
return time.time() - self.last_restart >= self.debounce_seconds

def _crash_restart_delay(self):
"""Return a backoff delay for repeated unexpected exits, or None if we should stop auto-retrying."""
now = time.time()
self.crash_timestamps = [
timestamp for timestamp in self.crash_timestamps
if now - timestamp <= self.crash_window_seconds
]
self.crash_timestamps.append(now)

if len(self.crash_timestamps) > self.max_crashes_in_window:
return None

delay = self.base_crash_restart_delay * (2 ** (len(self.crash_timestamps) - 1))
return min(delay, self.max_crash_restart_delay)

def _trigger_restart(self, event_path):
"""Handle a file change event."""
if not self._should_restart():
Expand Down Expand Up @@ -174,7 +194,25 @@ def signal_handler(signum, frame):
if handler.process and handler.process.poll() is not None:
exit_code = handler.process.returncode
if exit_code != 0:
print("\n Process exited with code {}. Waiting for file changes...".format(exit_code))
delay = handler._crash_restart_delay()
if delay is None:
print(
"\n Process exited with code {} too many times in {}s. Waiting for file changes...".format(
exit_code, int(handler.crash_window_seconds)
)
)
handler.process = None
else:
print(
"\n Process exited with code {}. Restarting in {:.1f}s...".format(
exit_code, delay
)
)
handler.process = None
time.sleep(delay)
handler.start_process()
else:
print("\n Process exited cleanly. Waiting for file changes...")
handler.process = None
except KeyboardInterrupt:
pass
Expand Down
2 changes: 1 addition & 1 deletion extensions/business/cybersec/red_mesh/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ class ScanType(str, Enum):
# Prompt-template version. Stored on every emitted LlmReportSections
# so the report's AI-disclosure appendix can show which template
# version produced the narrative. Bump on any prompt change.
LLM_PROMPT_VERSION_EXEC_SUMMARY = "exec-summary-v1"
LLM_PROMPT_VERSION_EXEC_SUMMARY = "exec-summary-v2"

# =====================================================================
# Protocol fingerprinting and probe routing
Expand Down
30 changes: 26 additions & 4 deletions extensions/business/cybersec/red_mesh/llm_input_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
MAX_FINDING_TITLE_CHARS = 200
MAX_FINDING_DESCRIPTION_CHARS = 600
MAX_FINDING_IMPACT_CHARS = 400
MAX_FINDING_REMEDIATION_CHARS = 600
MAX_EVIDENCE_CAPTION_CHARS = 240
MAX_EVIDENCE_SNIPPET_CHARS = 200
MAX_FINDINGS_INCLUDED = 80 # cap on findings forwarded to LLM
Expand Down Expand Up @@ -287,10 +288,30 @@ def _summarize_scan(
# Graybox-specific count fields
graybox = aggregated.get("graybox_results") or {}
if isinstance(graybox, dict):
routes = aggregated.get("scenario_stats") or {}
if isinstance(routes, dict):
out["routes_discovered"] = int(routes.get("routes_discovered", 0) or 0)
out["scenarios_tested"] = int(routes.get("scenarios_tested", 0) or 0)
discovery_count = 0
for port_data in graybox.values():
if not isinstance(port_data, dict):
continue
discovery = port_data.get("_graybox_discovery") or {}
if isinstance(discovery, dict):
routes = discovery.get("routes") or []
if isinstance(routes, (list, tuple)):
discovery_count += len(routes)
out["routes_discovered"] = discovery_count
scenario_stats = aggregated.get("scenario_stats") or {}
scan_metrics = aggregated.get("scan_metrics") or {}
if isinstance(scenario_stats, dict):
out["scenarios_tested"] = int(
scenario_stats.get("total")
or scenario_stats.get("scenarios_tested")
or 0
)
if not out["scenarios_tested"] and isinstance(scan_metrics, dict):
out["scenarios_tested"] = int(
scan_metrics.get("scenarios_total")
or scan_metrics.get("scenarios_tested")
or 0
)

return out

Expand All @@ -317,6 +338,7 @@ def _sanitize_finding(f: dict) -> dict:
"title": _sanitize(f.get("title", ""), MAX_FINDING_TITLE_CHARS),
"description": _sanitize(f.get("description", ""), MAX_FINDING_DESCRIPTION_CHARS),
"impact": _sanitize(f.get("impact", ""), MAX_FINDING_IMPACT_CHARS),
"remediation": _sanitize(f.get("remediation", ""), MAX_FINDING_REMEDIATION_CHARS),
"confidence": _sanitize(f.get("confidence", ""), 32),
"owasp_id": _sanitize(f.get("owasp_id", ""), 32),
"cwe_id": _sanitize(f.get("cwe_id", ""), 32),
Expand Down
17 changes: 16 additions & 1 deletion extensions/business/cybersec/red_mesh/mixins/llm_agent_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,21 @@ def _is_success(response_data):
self.P(f"Error calling LLM Agent API: {e}", color='r')
return {"error": str(e), "status": "error"}

if result is None:
self.P("LLM Agent API call exhausted retries without a response", color='y')
return {
"error": "LLM Agent API call exhausted retries without a response",
"status": "retry_exhausted",
"retryable": True,
}
if not isinstance(result, dict):
self.P("LLM Agent API returned an invalid response", color='y')
return {
"error": "LLM Agent API returned an invalid response",
"status": "invalid_response",
"retryable": True,
}

if isinstance(result, dict) and "error" in result:
status = result.get("status")
if status == "connection_error":
Expand Down Expand Up @@ -967,7 +982,7 @@ def _get_llm_health_status(self) -> dict:

result = self._call_llm_agent_api(endpoint="/health", method="GET", timeout=5)

if "error" in result:
if isinstance(result, dict) and "error" in result:
return {
"enabled": True,
"status": result.get("status", "error"),
Expand Down
66 changes: 63 additions & 3 deletions extensions/business/cybersec/red_mesh/mixins/redmesh_llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ class PentesterApi01Plugin(_LlmAgentMixin, BasePlugin):
from ..constants import RUN_MODE_SINGLEPASS
from ..services.config import get_llm_agent_config
from ..services.resilience import run_bounded_retry
from ..services.llm_structured import generate_exec_summary
from ..services.llm_structured import (
PROMPT_PROFILE_AUTO,
PROVIDER_PATH_REMOTE,
build_response_format_for_prompt_profile,
generate_exec_summary,
infer_provider_path,
resolve_prompt_profile,
)

_NON_RETRYABLE_HTTP_STATUSES = {400, 401, 403, 404, 409, 410, 413, 422}
_NON_RETRYABLE_PROVIDER_STATUSES = _NON_RETRYABLE_HTTP_STATUSES
Expand Down Expand Up @@ -1040,6 +1047,21 @@ def _is_success(response_data):
self.P(f"Error calling LLM Agent API: {e}", color='r')
return {"error": str(e), "status": "error"}

if result is None:
self.P("LLM Agent API call exhausted retries without a response", color='y')
return {
"error": "LLM Agent API call exhausted retries without a response",
"status": "retry_exhausted",
"retryable": True,
}
if not isinstance(result, dict):
self.P("LLM Agent API returned an invalid response", color='y')
return {
"error": "LLM Agent API returned an invalid response",
"status": "invalid_response",
"retryable": True,
}

if isinstance(result, dict) and "error" in result:
status = result.get("status")
if status == "connection_error":
Expand Down Expand Up @@ -1236,9 +1258,39 @@ def _run_structured_report_sections(
llm_cfg = get_llm_agent_config(self)
self._last_structured_llm_failed = None
self._last_structured_llm_validation = None
self._last_structured_llm_profile = None
self._last_structured_llm_provider_path = None
if not llm_cfg.get("ENABLED"):
return None

model_name = llm_cfg.get("MODEL", "CyberSecQwen-4B.Q4_K_M.gguf")
provider_path = llm_cfg.get("PROVIDER", "local")
requested_profile = llm_cfg.get("PROMPT_PROFILE", PROMPT_PROFILE_AUTO)
if requested_profile == PROMPT_PROFILE_AUTO:
# Resolve the auto profile through the same provider inference the
# structured service uses, so that an "auto" provider (or any value
# outside the literal remote set) still picks the remote profile when
# the model itself is remote (e.g. deepseek-chat). Pre-resolving to a
# concrete profile name here would otherwise short-circuit the
# model-based inference inside resolve_prompt_profile.
effective_path = infer_provider_path(
provider_path=provider_path,
model_name=model_name,
)
requested_profile = (
llm_cfg.get("REMOTE_PROMPT_PROFILE")
if effective_path == PROVIDER_PATH_REMOTE
else llm_cfg.get("LOCAL_PROMPT_PROFILE")
)
profile = resolve_prompt_profile(
requested_profile,
provider_path=provider_path,
model_name=model_name,
)
response_format = build_response_format_for_prompt_profile(profile)
self._last_structured_llm_profile = profile.id
self._last_structured_llm_provider_path = profile.provider_path

def raw_chat_call(messages: list, max_tokens: int, temperature: float) -> str:
"""Adapter — turns the LLM Agent /chat HTTP response into the
raw assistant content string generate_exec_summary expects."""
Expand All @@ -1249,6 +1301,7 @@ def raw_chat_call(messages: list, max_tokens: int, temperature: float) -> str:
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"response_format": response_format,
},
)
if not isinstance(response, dict) or "error" in response:
Expand All @@ -1266,7 +1319,12 @@ def raw_chat_call(messages: list, max_tokens: int, temperature: float) -> str:
findings=findings or [],
aggregated_report=aggregated_report,
engagement=engagement,
model_name=llm_cfg.get("MODEL", "deepseek-chat"),
model_name=model_name,
provider_path=provider_path,
prompt_profile=profile.id,
max_findings=llm_cfg.get("STRUCTURED_MAX_FINDINGS", 1),
max_tokens=llm_cfg.get("STRUCTURED_MAX_TOKENS", 1024),
temperature=llm_cfg.get("STRUCTURED_TEMPERATURE"),
)
except Exception as exc:
self.P(
Expand All @@ -1292,6 +1350,8 @@ def raw_chat_call(messages: list, max_tokens: int, temperature: float) -> str:
self._log_structured_llm_failure_diagnostics(job_id, result)

sections = result.sections.to_dict()
sections["prompt_profile"] = result.prompt_profile
sections["provider_path"] = result.provider_path
if result.error:
sections["validation"] = result.validation.to_dict()
sections["error"] = True
Expand Down Expand Up @@ -1431,7 +1491,7 @@ def _get_llm_health_status(self) -> dict:

result = self._call_llm_agent_api(endpoint="/health", method="GET", timeout=5)

if "error" in result:
if isinstance(result, dict) and "error" in result:
return {
"enabled": True,
"status": result.get("status", "error"),
Expand Down
8 changes: 8 additions & 0 deletions extensions/business/cybersec/red_mesh/pentester_api_01.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@
"ENABLED": False, # Enable LLM-powered analysis
"TIMEOUT": 120, # Timeout in seconds for LLM API calls
"AUTO_ANALYSIS_TYPE": "security_assessment", # Default analysis type
"PROVIDER": "local", # local is privacy-preserving default; remote is explicit opt-in
"MODEL": "CyberSecQwen-4B.Q4_K_M.gguf", # Provenance label for structured reports
"PROMPT_PROFILE": "auto", # auto selects local or remote structured prompt profile
"LOCAL_PROMPT_PROFILE": "local_cybersecqwen_quota_v1",
"REMOTE_PROMPT_PROFILE": "remote_rich_v1",
"STRUCTURED_MAX_FINDINGS": 6,
"STRUCTURED_MAX_TOKENS": 2048,
"STRUCTURED_TEMPERATURE": None,
},
"LLM_AGENT_API_HOST": "127.0.0.1", # Host where LLM Agent API is running
"LLM_AGENT_API_PORT": None, # Port for LLM Agent API (required if enabled)
Expand Down
Loading
Loading