diff --git a/README.md b/README.md index 571e237..de834b3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Built on [Strands SDK](https://github.com/strands-agents/sdk-python) and integra - [exploit-complexity](#manus-agent-exploit-complexity-cve-id--exploit-complexity-scorer) - [poc-search](#manus-agent-poc-search-cve-id--multi-source-poc-aggregator) - [blast-radius](#manus-agent-blast-radius-spec--dependency-blast-radius) + - [threat-feeds](#manus-agent-threat-feeds-cve-id--threat-intelligence-feeds) - [silent-patches](#manus-agent-silent-patches-ownerrepo--silent-patch-detector) - [cve-timeline](#manus-agent-cve-timeline-cve-id--cve-timeline) - [version-range](#manus-agent-version-range-cve-id--affected-version-ranges) @@ -344,6 +345,21 @@ Blast-radius labels per package: --- +### `manus-agent threat-feeds ` — Threat intelligence feeds + +```bash +manus-agent threat-feeds CVE-2024-3094 +manus-agent threat-feeds CVE-2024-3094 --output json | jq .intelligence +``` + +Queries curated open-source threat intelligence feeds (CISA advisories, etc.) for mentions of a given CVE ID. Returns matching snippets and feed metadata to help identify threat actor activity, campaigns, and broader context of exploitation. + +| Flag | Default | Description | +|------|---------|-------------| +| `--output {text,json}` | `text` | Output format | + +--- + ### `manus-agent silent-patches ` — Silent patch detector ```bash diff --git a/src/manus_agent/cli.py b/src/manus_agent/cli.py index e8442f2..7eaa970 100644 --- a/src/manus_agent/cli.py +++ b/src/manus_agent/cli.py @@ -1057,6 +1057,7 @@ def _run_variants(argv: list[str]) -> int: "poc-search", "changelog", "blast-radius", + "threat-feeds", } @@ -1935,6 +1936,86 @@ def _run_blast_radius(argv: list[str]) -> int: return 0 +# --------------------------------------------------------------------------- +# threat-feeds subcommand +# --------------------------------------------------------------------------- + + +def _build_threat_feeds_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="manus-agent threat-feeds", + description=( + "Query open-source threat intelligence feeds for a CVE.\n" + "Searches curated public feeds (CISA advisories, etc.) for mentions\n" + "of the given CVE ID and returns matching snippets and feed metadata." + ), + add_help=True, + ) + p.add_argument("cve_id", metavar="CVE-ID", help="CVE identifier, e.g. CVE-2024-3094") + p.add_argument( + "--output", + choices=["text", "json"], + default="text", + help="Output format (default: text)", + ) + return p + + +def _run_threat_feeds(argv: list[str]) -> int: + parser = _build_threat_feeds_parser() + args = parser.parse_args(argv) + cve_id = args.cve_id.strip() + + if not re.match(r"CVE-\d{4}-\d+", cve_id, re.IGNORECASE): + parser.error(f"Invalid CVE ID: {cve_id!r}. Expected format: CVE-YYYY-NNNNN") + + try: + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + except ImportError as exc: # pragma: no cover + print(f"Error: failed to import threat intelligence module: {exc}", file=sys.stderr) + return 1 + + payload = fetch_threat_intelligence(cve_id) + + if args.output == "json": + print(json.dumps(payload, indent=2)) + return 0 + + # --- text output --- + print() + print(f"Threat Intelligence Feeds — {cve_id}") + print("=" * 60) + print(payload["summary"]) + + if payload["intelligence"]: + print() + for i, entry in enumerate(payload["intelligence"], 1): + print(f" [{i}] {entry['feed_name']}") + print(f" URL: {entry['feed_url']}") + snippet = entry.get("snippet", "").strip() + if snippet: + # Truncate long snippets for terminal readability + display_snippet = snippet[:200] + "..." if len(snippet) > 200 else snippet + print(f" Snippet: {display_snippet}") + print() + + if payload["errors"]: + print("Errors encountered:") + for err in payload["errors"]: + print(f" ⚠ {err['feed_name']}: {err['error']}") + print() + + if not payload["intelligence"]: + print() + print("No mentions found in the curated threat intelligence feeds.") + print("This does not mean the CVE is not being exploited — it may") + print("simply not appear in the feeds currently monitored.") + + return 0 + + def _build_run_parser() -> argparse.ArgumentParser: """Build the top-level run/interactive parser.""" parser = argparse.ArgumentParser( @@ -2269,6 +2350,10 @@ def main() -> None: idx = argv.index("blast-radius") sys.exit(_run_blast_radius(argv[idx + 1 :])) + if first_positional == "threat-feeds": + idx = argv.index("threat-feeds") + sys.exit(_run_threat_feeds(argv[idx + 1 :])) + if first_positional == "discover": idx = argv.index("discover") discover_args = _build_discover_parser().parse_args(argv[idx + 1 :]) diff --git a/src/manus_agent/tools/query_threat_intelligence_feeds.py b/src/manus_agent/tools/query_threat_intelligence_feeds.py index 708a4e3..e467e74 100644 --- a/src/manus_agent/tools/query_threat_intelligence_feeds.py +++ b/src/manus_agent/tools/query_threat_intelligence_feeds.py @@ -32,71 +32,96 @@ }, } +# Default curated list of public threat intelligence feeds. +DEFAULT_THREAT_FEEDS: list[dict[str, str]] = [ + { + "name": "CISA Cybersecurity Advisories", + "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "type": "rss", + }, +] -def query_threat_intelligence_feeds(tool: ToolUse, **kwargs: Any) -> ToolResult: - tool_use_id = tool["toolUseId"] - tool_input = tool["input"] - cve_id = tool_input.get("cve_id") - if not isinstance(cve_id, str) or not cve_id.strip(): - result = { - "toolUseId": tool_use_id, - "status": "error", - "content": [{"text": "Invalid CVE ID. Must be a non-empty string."}], - } - log_tool_output_size("query_threat_intelligence_feeds", result) - return result +def fetch_threat_intelligence( + cve_id: str, + *, + feeds: list[dict[str, str]] | None = None, + timeout: int = 10, +) -> dict[str, Any]: + """Query threat intelligence feeds for a CVE. - # Curated list of public threat intelligence feeds (example URLs) - # In a real-world scenario, this list would be more extensive and potentially configurable. - # Parsing logic would also need to be more robust for different feed formats (RSS, JSON, HTML). - threat_feeds = [ - { - "name": "CISA Cybersecurity Advisories", - "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", # Updated RSS feed - "type": "rss", - }, - # Removed US-CERT Alerts as it was causing 404 errors and may be deprecated. - ] + Returns a dict with keys: + - summary (str): human-readable summary + - intelligence (list[dict]): list of feed match dicts + - errors (list[dict]): list of feed errors (feed_name, error) + """ + if feeds is None: + feeds = DEFAULT_THREAT_FEEDS found_intelligence: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] - for feed in threat_feeds: + for feed in feeds: try: - response = requests.get(feed["url"], timeout=10) + response = requests.get(feed["url"], timeout=timeout) response.raise_for_status() content = response.text # Basic search for CVE ID in the content if cve_id.upper() in content.upper(): - # In a real tool, you'd parse the RSS/JSON/HTML more intelligently - # to extract relevant snippets, titles, and links. + idx = content.upper().find(cve_id.upper()) + snippet_start = max(0, idx - 50) + snippet_end = min(len(content), idx + 100) found_intelligence.append( { "feed_name": feed["name"], "feed_url": feed["url"], "cve_found": cve_id, - "snippet": content[ - content.upper().find(cve_id.upper()) - 50 : content.upper().find(cve_id.upper()) + 100 - ] - + "...", # Basic snippet + "snippet": content[snippet_start:snippet_end] + "...", } ) except requests.exceptions.RequestException as e: - # Log the error but continue with other feeds - print(f"Error fetching {feed['name']} ({feed['url']}): {e}") + errors.append({"feed_name": feed["name"], "error": str(e)}) except Exception as e: - print(f"An unexpected error occurred with {feed['name']}: {e}") + errors.append({"feed_name": feed["name"], "error": str(e)}) if not found_intelligence: + summary = f"No direct threat intelligence found for {cve_id} in curated feeds." + else: + summary = f"Found relevant threat intelligence for {cve_id} in {len(found_intelligence)} feed(s)." + + return { + "summary": summary, + "intelligence": found_intelligence, + "errors": errors, + } + + +def query_threat_intelligence_feeds(tool: ToolUse, **kwargs: Any) -> ToolResult: + tool_use_id = tool["toolUseId"] + tool_input = tool["input"] + cve_id = tool_input.get("cve_id") + + if not isinstance(cve_id, str) or not cve_id.strip(): + result = { + "toolUseId": tool_use_id, + "status": "error", + "content": [{"text": "Invalid CVE ID. Must be a non-empty string."}], + } + log_tool_output_size("query_threat_intelligence_feeds", result) + return result + + payload = fetch_threat_intelligence(cve_id) + + if not payload["intelligence"]: result = { "toolUseId": tool_use_id, "status": "success", "content": [ { "json": { - "summary": f"No direct threat intelligence found for {cve_id} in curated feeds.", + "summary": payload["summary"], "intelligence": [], } } @@ -105,11 +130,10 @@ def query_threat_intelligence_feeds(tool: ToolUse, **kwargs: Any) -> ToolResult: log_tool_output_size("query_threat_intelligence_feeds", result) return result - summary = f"Found relevant threat intelligence for {cve_id} in {len(found_intelligence)} feeds." result = { "toolUseId": tool_use_id, "status": "success", - "content": [{"json": {"summary": summary, "intelligence": found_intelligence}}], + "content": [{"json": {"summary": payload["summary"], "intelligence": payload["intelligence"]}}], } log_tool_output_size("query_threat_intelligence_feeds", result) return result diff --git a/tests/test_cli_threat_feeds.py b/tests/test_cli_threat_feeds.py new file mode 100644 index 0000000..b578127 --- /dev/null +++ b/tests/test_cli_threat_feeds.py @@ -0,0 +1,403 @@ +"""Tests for manus-agent threat-feeds CLI subcommand.""" + +import json +import sys +from unittest.mock import patch + +import pytest + +from manus_agent.cli import _build_threat_feeds_parser, _run_threat_feeds + + +class TestBuildThreatFeedsParser: + """Tests for _build_threat_feeds_parser.""" + + def test_parser_prog(self): + p = _build_threat_feeds_parser() + assert p.prog == "manus-agent threat-feeds" + + def test_parser_requires_cve_id(self): + p = _build_threat_feeds_parser() + with pytest.raises(SystemExit): + p.parse_args([]) + + def test_parser_accepts_cve_id(self): + p = _build_threat_feeds_parser() + args = p.parse_args(["CVE-2024-3094"]) + assert args.cve_id == "CVE-2024-3094" + + def test_parser_default_output_is_text(self): + p = _build_threat_feeds_parser() + args = p.parse_args(["CVE-2024-3094"]) + assert args.output == "text" + + def test_parser_accepts_json_output(self): + p = _build_threat_feeds_parser() + args = p.parse_args(["CVE-2024-3094", "--output", "json"]) + assert args.output == "json" + + def test_parser_rejects_invalid_output(self): + p = _build_threat_feeds_parser() + with pytest.raises(SystemExit): + p.parse_args(["CVE-2024-3094", "--output", "xml"]) + + +class TestRunThreatFeedsValidation: + """Tests for CVE ID validation in _run_threat_feeds.""" + + def test_invalid_cve_id_exits_with_error(self): + with pytest.raises(SystemExit) as exc_info: + _run_threat_feeds(["not-a-cve"]) + assert exc_info.value.code == 2 + + def test_empty_string_exits_with_error(self): + with pytest.raises(SystemExit) as exc_info: + _run_threat_feeds([""]) + assert exc_info.value.code == 2 + + def test_partial_cve_exits_with_error(self): + with pytest.raises(SystemExit) as exc_info: + _run_threat_feeds(["CVE-2024"]) + assert exc_info.value.code == 2 + + def test_no_args_exits_with_error(self): + with pytest.raises(SystemExit) as exc_info: + _run_threat_feeds([]) + assert exc_info.value.code == 2 + + +class TestRunThreatFeedsNoResults: + """Tests for _run_threat_feeds when no intelligence is found.""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_no_results_text_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "No direct threat intelligence found for CVE-2024-9999 in curated feeds.", + "intelligence": [], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-9999"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "No direct threat intelligence found" in captured.out + assert "CVE-2024-9999" in captured.out + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_no_results_json_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "No direct threat intelligence found for CVE-2024-9999 in curated feeds.", + "intelligence": [], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-9999", "--output", "json"]) + assert exit_code == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["intelligence"] == [] + assert "No direct threat intelligence found" in data["summary"] + + +class TestRunThreatFeedsWithResults: + """Tests for _run_threat_feeds when intelligence is found.""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_results_text_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 1 feed(s).", + "intelligence": [ + { + "feed_name": "CISA Cybersecurity Advisories", + "feed_url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "cve_found": "CVE-2024-3094", + "snippet": "...advisory about CVE-2024-3094 affecting xz-utils...", + } + ], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-3094"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "CISA Cybersecurity Advisories" in captured.out + assert "CVE-2024-3094" in captured.out + assert "Threat Intelligence Feeds" in captured.out + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_results_json_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 1 feed(s).", + "intelligence": [ + { + "feed_name": "CISA Cybersecurity Advisories", + "feed_url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "cve_found": "CVE-2024-3094", + "snippet": "...advisory about CVE-2024-3094...", + } + ], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-3094", "--output", "json"]) + assert exit_code == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data["intelligence"]) == 1 + assert data["intelligence"][0]["feed_name"] == "CISA Cybersecurity Advisories" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_multiple_feeds_text_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 2 feed(s).", + "intelligence": [ + { + "feed_name": "CISA Cybersecurity Advisories", + "feed_url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "cve_found": "CVE-2024-3094", + "snippet": "snippet1", + }, + { + "feed_name": "Another Feed", + "feed_url": "https://example.com/feed.xml", + "cve_found": "CVE-2024-3094", + "snippet": "snippet2", + }, + ], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-3094"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "[1]" in captured.out + assert "[2]" in captured.out + assert "Another Feed" in captured.out + + +class TestRunThreatFeedsWithErrors: + """Tests for _run_threat_feeds when feeds have errors.""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_errors_displayed_in_text_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "No direct threat intelligence found for CVE-2024-9999 in curated feeds.", + "intelligence": [], + "errors": [ + {"feed_name": "Bad Feed", "error": "Connection timeout"}, + ], + } + exit_code = _run_threat_feeds(["CVE-2024-9999"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "Errors encountered" in captured.out + assert "Bad Feed" in captured.out + assert "Connection timeout" in captured.out + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_errors_included_in_json_output(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "No direct threat intelligence found for CVE-2024-9999 in curated feeds.", + "intelligence": [], + "errors": [ + {"feed_name": "Bad Feed", "error": "Connection timeout"}, + ], + } + exit_code = _run_threat_feeds(["CVE-2024-9999", "--output", "json"]) + assert exit_code == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data["errors"]) == 1 + assert data["errors"][0]["feed_name"] == "Bad Feed" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_partial_success_with_errors(self, mock_fetch, capsys): + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 1 feed(s).", + "intelligence": [ + { + "feed_name": "CISA Cybersecurity Advisories", + "feed_url": "https://www.cisa.gov/feed.xml", + "cve_found": "CVE-2024-3094", + "snippet": "found it here...", + } + ], + "errors": [ + {"feed_name": "Failing Feed", "error": "HTTP 503"}, + ], + } + exit_code = _run_threat_feeds(["CVE-2024-3094"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "CISA Cybersecurity Advisories" in captured.out + assert "Failing Feed" in captured.out + assert "HTTP 503" in captured.out + + +class TestRunThreatFeedsSnippetTruncation: + """Tests for snippet display truncation.""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_long_snippet_is_truncated(self, mock_fetch, capsys): + long_snippet = "A" * 300 + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 1 feed(s).", + "intelligence": [ + { + "feed_name": "Test Feed", + "feed_url": "https://example.com/feed.xml", + "cve_found": "CVE-2024-3094", + "snippet": long_snippet, + } + ], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-3094"]) + assert exit_code == 0 + captured = capsys.readouterr() + # The displayed snippet should be truncated to 200 chars + "..." + assert "A" * 200 + "..." in captured.out + # Should NOT contain the full 300 chars + assert "A" * 300 not in captured.out + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_short_snippet_is_not_truncated(self, mock_fetch, capsys): + short_snippet = "Short CVE mention" + mock_fetch.return_value = { + "summary": "Found relevant threat intelligence for CVE-2024-3094 in 1 feed(s).", + "intelligence": [ + { + "feed_name": "Test Feed", + "feed_url": "https://example.com/feed.xml", + "cve_found": "CVE-2024-3094", + "snippet": short_snippet, + } + ], + "errors": [], + } + exit_code = _run_threat_feeds(["CVE-2024-3094"]) + assert exit_code == 0 + captured = capsys.readouterr() + assert "Short CVE mention" in captured.out + + +class TestFetchThreatIntelligence: + """Tests for the fetch_threat_intelligence helper function.""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_feed_match(self, mock_get): + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.return_value.status_code = 200 + mock_get.return_value.raise_for_status = lambda: None + mock_get.return_value.text = "This advisory covers CVE-2024-3094 affecting xz-utils" + + result = fetch_threat_intelligence("CVE-2024-3094") + assert len(result["intelligence"]) == 1 + assert result["intelligence"][0]["cve_found"] == "CVE-2024-3094" + assert result["errors"] == [] + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_no_match(self, mock_get): + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.return_value.status_code = 200 + mock_get.return_value.raise_for_status = lambda: None + mock_get.return_value.text = "No CVEs mentioned here at all" + + result = fetch_threat_intelligence("CVE-2024-9999") + assert result["intelligence"] == [] + assert "No direct threat intelligence found" in result["summary"] + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_case_insensitive_match(self, mock_get): + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.return_value.status_code = 200 + mock_get.return_value.raise_for_status = lambda: None + mock_get.return_value.text = "Contains cve-2024-3094 in lower case" + + result = fetch_threat_intelligence("CVE-2024-3094") + assert len(result["intelligence"]) == 1 + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_request_error_captured(self, mock_get): + import requests + + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused") + + result = fetch_threat_intelligence("CVE-2024-3094") + assert result["intelligence"] == [] + assert len(result["errors"]) == 1 + assert "Connection refused" in result["errors"][0]["error"] + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_timeout_error_captured(self, mock_get): + import requests + + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.side_effect = requests.exceptions.Timeout("Request timed out") + + result = fetch_threat_intelligence("CVE-2024-3094") + assert result["intelligence"] == [] + assert len(result["errors"]) == 1 + assert "timed out" in result["errors"][0]["error"] + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_custom_feeds(self, mock_get): + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + mock_get.return_value.status_code = 200 + mock_get.return_value.raise_for_status = lambda: None + mock_get.return_value.text = "Advisory: CVE-2024-3094 is critical" + + custom_feeds = [ + {"name": "Custom Feed", "url": "https://custom.example.com/feed.xml", "type": "rss"}, + ] + result = fetch_threat_intelligence("CVE-2024-3094", feeds=custom_feeds) + assert len(result["intelligence"]) == 1 + assert result["intelligence"][0]["feed_name"] == "Custom Feed" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.requests.get") + def test_snippet_extraction_bounds(self, mock_get): + from manus_agent.tools.query_threat_intelligence_feeds import ( + fetch_threat_intelligence, + ) + + # CVE at the very start of content (no 50 chars before it) + mock_get.return_value.status_code = 200 + mock_get.return_value.raise_for_status = lambda: None + mock_get.return_value.text = "CVE-2024-3094 is critical" + + result = fetch_threat_intelligence("CVE-2024-3094") + assert len(result["intelligence"]) == 1 + # Snippet should start from beginning since CVE is at index 0 + assert "CVE-2024-3094" in result["intelligence"][0]["snippet"] + + +class TestMainDispatch: + """Tests for threat-feeds dispatch in main().""" + + @patch("manus_agent.tools.query_threat_intelligence_feeds.fetch_threat_intelligence") + def test_main_dispatches_threat_feeds(self, mock_fetch): + mock_fetch.return_value = { + "summary": "No results", + "intelligence": [], + "errors": [], + } + with patch.object(sys, "argv", ["manus-agent", "threat-feeds", "CVE-2024-3094"]): + from manus_agent.cli import main + + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0