malwar 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. malwar/__init__.py +25 -0
  2. malwar/__main__.py +6 -0
  3. malwar/api/__init__.py +1 -0
  4. malwar/api/app.py +90 -0
  5. malwar/api/auth.py +35 -0
  6. malwar/api/middleware.py +181 -0
  7. malwar/api/routes/__init__.py +1 -0
  8. malwar/api/routes/analytics.py +166 -0
  9. malwar/api/routes/campaigns.py +115 -0
  10. malwar/api/routes/export.py +136 -0
  11. malwar/api/routes/feed.py +229 -0
  12. malwar/api/routes/health.py +38 -0
  13. malwar/api/routes/ingest.py +76 -0
  14. malwar/api/routes/reports.py +205 -0
  15. malwar/api/routes/scan.py +341 -0
  16. malwar/api/routes/signatures.py +192 -0
  17. malwar/cli/__init__.py +1 -0
  18. malwar/cli/app.py +439 -0
  19. malwar/cli/commands/__init__.py +1 -0
  20. malwar/cli/commands/db.py +103 -0
  21. malwar/cli/commands/export.py +99 -0
  22. malwar/cli/commands/ingest.py +152 -0
  23. malwar/cli/formatters/__init__.py +1 -0
  24. malwar/cli/formatters/console.py +102 -0
  25. malwar/cli/formatters/json_fmt.py +30 -0
  26. malwar/cli/formatters/sarif.py +88 -0
  27. malwar/core/__init__.py +1 -0
  28. malwar/core/config.py +88 -0
  29. malwar/core/constants.py +54 -0
  30. malwar/core/exceptions.py +38 -0
  31. malwar/core/logging.py +56 -0
  32. malwar/detectors/__init__.py +1 -0
  33. malwar/detectors/llm_analyzer/__init__.py +1 -0
  34. malwar/detectors/llm_analyzer/detector.py +137 -0
  35. malwar/detectors/llm_analyzer/parser.py +189 -0
  36. malwar/detectors/llm_analyzer/prompts.py +95 -0
  37. malwar/detectors/rule_engine/__init__.py +1 -0
  38. malwar/detectors/rule_engine/base_rule.py +26 -0
  39. malwar/detectors/rule_engine/detector.py +63 -0
  40. malwar/detectors/rule_engine/registry.py +43 -0
  41. malwar/detectors/rule_engine/rules/__init__.py +1 -0
  42. malwar/detectors/rule_engine/rules/agent_hijacking.py +83 -0
  43. malwar/detectors/rule_engine/rules/credential_exposure.py +102 -0
  44. malwar/detectors/rule_engine/rules/env_harvesting.py +93 -0
  45. malwar/detectors/rule_engine/rules/exfiltration.py +150 -0
  46. malwar/detectors/rule_engine/rules/known_malware.py +117 -0
  47. malwar/detectors/rule_engine/rules/multi_step.py +93 -0
  48. malwar/detectors/rule_engine/rules/obfuscation.py +138 -0
  49. malwar/detectors/rule_engine/rules/persistence.py +163 -0
  50. malwar/detectors/rule_engine/rules/prompt_injection.py +136 -0
  51. malwar/detectors/rule_engine/rules/social_engineering.py +193 -0
  52. malwar/detectors/rule_engine/rules/steganography.py +169 -0
  53. malwar/detectors/rule_engine/rules/supply_chain.py +168 -0
  54. malwar/detectors/rule_engine/rules/suspicious_commands.py +180 -0
  55. malwar/detectors/threat_intel/__init__.py +1 -0
  56. malwar/detectors/threat_intel/detector.py +55 -0
  57. malwar/detectors/threat_intel/matcher.py +299 -0
  58. malwar/detectors/url_crawler/__init__.py +6 -0
  59. malwar/detectors/url_crawler/analyzer.py +247 -0
  60. malwar/detectors/url_crawler/detector.py +144 -0
  61. malwar/detectors/url_crawler/extractor.py +113 -0
  62. malwar/detectors/url_crawler/fetcher.py +222 -0
  63. malwar/detectors/url_crawler/reputation.py +138 -0
  64. malwar/export/__init__.py +24 -0
  65. malwar/export/stix.py +330 -0
  66. malwar/export/taxii.py +139 -0
  67. malwar/ingestion/__init__.py +27 -0
  68. malwar/ingestion/importer.py +238 -0
  69. malwar/ingestion/schema.py +63 -0
  70. malwar/ingestion/sources.py +479 -0
  71. malwar/integrations/__init__.py +16 -0
  72. malwar/integrations/exceptions.py +40 -0
  73. malwar/integrations/langchain.py +419 -0
  74. malwar/models/__init__.py +17 -0
  75. malwar/models/finding.py +39 -0
  76. malwar/models/report.py +27 -0
  77. malwar/models/sarif.py +70 -0
  78. malwar/models/scan.py +78 -0
  79. malwar/models/signature.py +43 -0
  80. malwar/models/skill.py +57 -0
  81. malwar/notifications/__init__.py +6 -0
  82. malwar/notifications/webhook.py +140 -0
  83. malwar/parsers/__init__.py +13 -0
  84. malwar/parsers/markdown_parser.py +125 -0
  85. malwar/parsers/skill_parser.py +121 -0
  86. malwar/py.typed +0 -0
  87. malwar/scanner/__init__.py +1 -0
  88. malwar/scanner/base.py +36 -0
  89. malwar/scanner/context.py +48 -0
  90. malwar/scanner/pipeline.py +144 -0
  91. malwar/scanner/severity.py +35 -0
  92. malwar/sdk.py +269 -0
  93. malwar/storage/__init__.py +22 -0
  94. malwar/storage/database.py +67 -0
  95. malwar/storage/migrations.py +532 -0
  96. malwar/storage/repositories/__init__.py +16 -0
  97. malwar/storage/repositories/campaigns.py +33 -0
  98. malwar/storage/repositories/findings.py +74 -0
  99. malwar/storage/repositories/publishers.py +44 -0
  100. malwar/storage/repositories/scans.py +132 -0
  101. malwar/storage/repositories/signatures.py +133 -0
  102. malwar-0.2.1.dist-info/METADATA +209 -0
  103. malwar-0.2.1.dist-info/RECORD +106 -0
  104. malwar-0.2.1.dist-info/WHEEL +4 -0
  105. malwar-0.2.1.dist-info/entry_points.txt +2 -0
  106. malwar-0.2.1.dist-info/licenses/LICENSE +68 -0
malwar/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """malwar - Malware detection engine for agentic skills."""
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ from malwar.integrations.exceptions import MalwarBlockedError
7
+ from malwar.integrations.langchain import (
8
+ MalwarCallbackHandler,
9
+ MalwarGuard,
10
+ MalwarScanTool,
11
+ )
12
+ from malwar.sdk import scan, scan_batch, scan_file, scan_file_sync, scan_sync
13
+
14
+ __all__ = [
15
+ "MalwarBlockedError",
16
+ "MalwarCallbackHandler",
17
+ "MalwarGuard",
18
+ "MalwarScanTool",
19
+ "__version__",
20
+ "scan",
21
+ "scan_batch",
22
+ "scan_file",
23
+ "scan_file_sync",
24
+ "scan_sync",
25
+ ]
malwar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """Allow running malwar as a module: python -m malwar."""
3
+
4
+ from malwar.cli.app import app
5
+
6
+ app()
malwar/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
malwar/api/app.py ADDED
@@ -0,0 +1,90 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """FastAPI application factory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from malwar.api.middleware import RateLimitMiddleware, RequestMiddleware, UsageLoggingMiddleware
16
+ from malwar.api.routes import (
17
+ analytics,
18
+ campaigns,
19
+ export,
20
+ feed,
21
+ health,
22
+ ingest,
23
+ reports,
24
+ scan,
25
+ signatures,
26
+ )
27
+
28
+ _WEB_DIST = Path(__file__).resolve().parent.parent.parent.parent / "web" / "dist"
29
+
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
33
+ from malwar.core.config import get_settings
34
+ from malwar.storage.database import close_db, init_db
35
+
36
+ settings = get_settings()
37
+ await init_db(settings.db_path, auto_migrate=settings.auto_migrate)
38
+ yield
39
+ await close_db()
40
+
41
+
42
+ def create_app() -> FastAPI:
43
+ app = FastAPI(
44
+ title="malwar",
45
+ description="Malware detection engine for agentic skills",
46
+ version="0.1.0",
47
+ lifespan=lifespan,
48
+ docs_url="/api/docs",
49
+ redoc_url="/api/redoc",
50
+ openapi_url="/api/openapi.json",
51
+ )
52
+
53
+ # CORS for development (Vite dev server)
54
+ app.add_middleware(
55
+ CORSMiddleware,
56
+ allow_origins=["http://localhost:3000"],
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+ app.include_router(health.router, prefix="/api/v1", tags=["health"])
62
+ app.include_router(scan.router, prefix="/api/v1", tags=["scan"])
63
+ app.include_router(campaigns.router, prefix="/api/v1", tags=["campaigns"])
64
+ app.include_router(signatures.router, prefix="/api/v1", tags=["signatures"])
65
+ app.include_router(reports.router, prefix="/api/v1", tags=["reports"])
66
+ app.include_router(feed.router, prefix="/api/v1", tags=["feed"])
67
+ app.include_router(analytics.router, prefix="/api/v1", tags=["analytics"])
68
+ app.include_router(export.router, prefix="/api/v1", tags=["export"])
69
+ app.include_router(ingest.router, prefix="/api/v1", tags=["ingest"])
70
+ app.add_middleware(UsageLoggingMiddleware)
71
+ app.add_middleware(RequestMiddleware)
72
+ app.add_middleware(RateLimitMiddleware)
73
+
74
+ # Serve frontend in production (when web/dist exists)
75
+ if _WEB_DIST.is_dir():
76
+ _index = str(_WEB_DIST / "index.html")
77
+
78
+ # Mount static files for assets
79
+ app.mount("/assets", StaticFiles(directory=str(_WEB_DIST / "assets")), name="assets")
80
+
81
+ # Catch-all for SPA client-side routes
82
+ @app.api_route("/{path:path}", methods=["GET"], include_in_schema=False)
83
+ async def _spa_fallback(request: Request, path: str) -> FileResponse:
84
+ # Serve actual static files if they exist (favicon, vite.svg, etc.)
85
+ static_file = _WEB_DIST / path
86
+ if path and static_file.is_file():
87
+ return FileResponse(str(static_file))
88
+ return FileResponse(_index)
89
+
90
+ return app
malwar/api/auth.py ADDED
@@ -0,0 +1,35 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """API key authentication dependency."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from fastapi import HTTPException, Security
7
+ from fastapi.security import APIKeyHeader
8
+
9
+ from malwar.core.config import get_settings
10
+
11
+ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
12
+
13
+
14
+ async def require_api_key(
15
+ api_key: str | None = Security(_api_key_header),
16
+ ) -> str:
17
+ """Validate the X-API-Key header.
18
+
19
+ If no API keys are configured in settings, authentication is disabled
20
+ (open access). Otherwise the provided key must match one of the
21
+ configured keys.
22
+ """
23
+ settings = get_settings()
24
+
25
+ # No keys configured = auth disabled
26
+ if not settings.api_keys:
27
+ return "anonymous"
28
+
29
+ if not api_key:
30
+ raise HTTPException(status_code=401, detail="Missing X-API-Key header")
31
+
32
+ if api_key not in settings.api_keys:
33
+ raise HTTPException(status_code=403, detail="Invalid API key")
34
+
35
+ return api_key
@@ -0,0 +1,181 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """Request middleware for logging, request ID tracking, rate limiting, and usage metering."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from collections import defaultdict
10
+ from datetime import UTC, datetime
11
+
12
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
13
+ from starlette.requests import Request
14
+ from starlette.responses import JSONResponse, Response
15
+
16
+ from malwar.core.config import get_settings
17
+
18
+ logger = logging.getLogger("malwar.api.middleware")
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Rate-limit state (in-memory, per-process)
22
+ # ---------------------------------------------------------------------------
23
+ _request_log: dict[str, list[float]] = defaultdict(list)
24
+ _CLEANUP_INTERVAL = 60.0 # seconds between full sweeps
25
+ _last_cleanup: float = 0.0
26
+
27
+ _RATE_LIMIT_SKIP_PATHS: set[str] = {"/api/v1/health"}
28
+
29
+
30
+ def _cleanup_old_entries(now: float, window: float) -> None:
31
+ """Remove timestamps older than *window* seconds for every tracked key."""
32
+ global _last_cleanup
33
+ expired_keys: list[str] = []
34
+ for key, timestamps in _request_log.items():
35
+ _request_log[key] = [t for t in timestamps if now - t < window]
36
+ if not _request_log[key]:
37
+ expired_keys.append(key)
38
+ for key in expired_keys:
39
+ del _request_log[key]
40
+ _last_cleanup = now
41
+
42
+
43
+ class RateLimitMiddleware(BaseHTTPMiddleware):
44
+ """In-memory rate limiter with per-API-key and per-IP support.
45
+
46
+ * Authenticated requests (with ``X-API-Key`` header): limited to
47
+ ``rate_limit_per_key`` requests per minute (default 600).
48
+ * Unauthenticated requests: limited to ``rate_limit_per_ip`` requests
49
+ per minute (default 60), keyed by client IP.
50
+ * Returns **429 Too Many Requests** with a ``Retry-After`` header.
51
+ * Adds ``X-RateLimit-Limit``, ``X-RateLimit-Remaining``, and
52
+ ``X-RateLimit-Reset`` headers on all responses.
53
+ * Skips the ``/api/v1/health`` endpoint.
54
+ """
55
+
56
+ async def dispatch(
57
+ self, request: Request, call_next: RequestResponseEndpoint
58
+ ) -> Response:
59
+ if request.url.path in _RATE_LIMIT_SKIP_PATHS:
60
+ return await call_next(request)
61
+
62
+ settings = get_settings()
63
+ window = 60.0 # seconds
64
+
65
+ # Determine identity and limit: API key takes precedence over IP
66
+ api_key = request.headers.get("X-API-Key")
67
+ if api_key:
68
+ identity = f"key:{api_key}"
69
+ limit = settings.rate_limit_per_key
70
+ else:
71
+ client_ip = request.client.host if request.client else "unknown"
72
+ identity = f"ip:{client_ip}"
73
+ limit = settings.rate_limit_per_ip
74
+
75
+ now = time.monotonic()
76
+
77
+ # Periodic cleanup to avoid memory leaks
78
+ global _last_cleanup
79
+ if now - _last_cleanup > _CLEANUP_INTERVAL:
80
+ _cleanup_old_entries(now, window)
81
+
82
+ # Prune this identity's old timestamps
83
+ timestamps = _request_log[identity]
84
+ _request_log[identity] = [t for t in timestamps if now - t < window]
85
+ timestamps = _request_log[identity]
86
+
87
+ remaining = max(0, limit - len(timestamps))
88
+ # Compute seconds until the oldest request in the window expires
89
+ if timestamps:
90
+ oldest = min(timestamps)
91
+ reset_seconds = int(window - (now - oldest)) + 1
92
+ else:
93
+ reset_seconds = int(window)
94
+
95
+ if len(timestamps) >= limit:
96
+ return JSONResponse(
97
+ status_code=429,
98
+ content={"detail": "Rate limit exceeded"},
99
+ headers={
100
+ "Retry-After": str(reset_seconds),
101
+ "X-RateLimit-Limit": str(limit),
102
+ "X-RateLimit-Remaining": "0",
103
+ "X-RateLimit-Reset": str(reset_seconds),
104
+ },
105
+ )
106
+
107
+ timestamps.append(now)
108
+ remaining = max(0, limit - len(timestamps))
109
+
110
+ response = await call_next(request)
111
+
112
+ # Add rate-limit headers to every successful response
113
+ response.headers["X-RateLimit-Limit"] = str(limit)
114
+ response.headers["X-RateLimit-Remaining"] = str(remaining)
115
+ response.headers["X-RateLimit-Reset"] = str(reset_seconds)
116
+
117
+ return response
118
+
119
+
120
+ class UsageLoggingMiddleware(BaseHTTPMiddleware):
121
+ """Logs API usage to the ``api_usage`` table after each request.
122
+
123
+ Gracefully degrades: if the table does not exist (migration not yet
124
+ applied), the error is silently swallowed so the request still succeeds.
125
+ """
126
+
127
+ async def dispatch(
128
+ self, request: Request, call_next: RequestResponseEndpoint
129
+ ) -> Response:
130
+ response = await call_next(request)
131
+
132
+ # Fire-and-forget usage logging; never block the response
133
+ try:
134
+ api_key = request.headers.get("X-API-Key", "")
135
+ endpoint = request.url.path
136
+ method = request.method
137
+ status_code = response.status_code
138
+ timestamp = datetime.now(UTC).isoformat()
139
+
140
+ from malwar.storage.database import get_db
141
+
142
+ db = await get_db()
143
+ await db.execute(
144
+ """
145
+ INSERT INTO api_usage (api_key, endpoint, method, status_code, timestamp)
146
+ VALUES (?, ?, ?, ?, ?)
147
+ """,
148
+ (api_key, endpoint, method, status_code, timestamp),
149
+ )
150
+ await db.commit()
151
+ except Exception:
152
+ # Graceful degradation: table may not exist yet, DB may not be
153
+ # initialized, etc. Never let usage logging break the request.
154
+ pass
155
+
156
+ return response
157
+
158
+
159
+ class RequestMiddleware(BaseHTTPMiddleware):
160
+ """Adds request logging and X-Request-ID header to every response."""
161
+
162
+ async def dispatch(
163
+ self, request: Request, call_next: RequestResponseEndpoint
164
+ ) -> Response:
165
+ request_id = uuid.uuid4().hex
166
+ start = time.monotonic()
167
+
168
+ response = await call_next(request)
169
+
170
+ duration_ms = round((time.monotonic() - start) * 1000, 1)
171
+ response.headers["X-Request-ID"] = request_id
172
+
173
+ logger.info(
174
+ "%s %s %s %.1fms",
175
+ request.method,
176
+ request.url.path,
177
+ response.status_code,
178
+ duration_ms,
179
+ )
180
+
181
+ return response
@@ -0,0 +1 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
@@ -0,0 +1,166 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """Analytics API endpoints for API usage metering."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from datetime import date
8
+
9
+ from fastapi import APIRouter, Depends, Query
10
+ from pydantic import BaseModel
11
+
12
+ from malwar.api.auth import require_api_key
13
+
14
+ logger = logging.getLogger("malwar.api.analytics")
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Response models
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class DailyBucket(BaseModel):
25
+ date: str
26
+ count: int
27
+
28
+
29
+ class EndpointCount(BaseModel):
30
+ endpoint: str
31
+ count: int
32
+
33
+
34
+ class VerdictCount(BaseModel):
35
+ verdict: str
36
+ count: int
37
+
38
+
39
+ class AnalyticsResponse(BaseModel):
40
+ total_requests: int
41
+ requests_by_endpoint: list[EndpointCount]
42
+ requests_by_verdict: list[VerdictCount]
43
+ requests_over_time: list[DailyBucket]
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ async def _table_exists(db: object, table_name: str) -> bool:
52
+ """Check whether *table_name* exists in the database."""
53
+ cursor = await db.execute( # type: ignore[attr-defined]
54
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
55
+ (table_name,),
56
+ )
57
+ return await cursor.fetchone() is not None
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Endpoints
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ @router.get("/analytics", response_model=AnalyticsResponse)
66
+ async def get_analytics(
67
+ start_date: date | None = Query(default=None, description="Start date (YYYY-MM-DD)"), # noqa: B008
68
+ end_date: date | None = Query(default=None, description="End date (YYYY-MM-DD)"), # noqa: B008
69
+ api_key: str | None = Query(default=None, description="Filter by API key"),
70
+ _auth_key: str = Depends(require_api_key),
71
+ ) -> AnalyticsResponse:
72
+ """Return API usage statistics.
73
+
74
+ Supports optional filtering by date range and API key.
75
+ Returns empty stats if the ``api_usage`` table has not been created yet.
76
+ """
77
+ from malwar.storage.database import get_db
78
+
79
+ db = await get_db()
80
+
81
+ # Graceful degradation: if table doesn't exist, return empty stats
82
+ if not await _table_exists(db, "api_usage"):
83
+ return AnalyticsResponse(
84
+ total_requests=0,
85
+ requests_by_endpoint=[],
86
+ requests_by_verdict=[],
87
+ requests_over_time=[],
88
+ )
89
+
90
+ # Build WHERE clause dynamically
91
+ conditions: list[str] = []
92
+ params: list[str] = []
93
+
94
+ if start_date is not None:
95
+ conditions.append("timestamp >= ?")
96
+ params.append(start_date.isoformat())
97
+ if end_date is not None:
98
+ conditions.append("timestamp < date(?, '+1 day')")
99
+ params.append(end_date.isoformat())
100
+ if api_key is not None:
101
+ conditions.append("api_key = ?")
102
+ params.append(api_key)
103
+
104
+ where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
105
+
106
+ # Total requests
107
+ cursor = await db.execute(
108
+ f"SELECT COUNT(*) FROM api_usage{where}", params # noqa: S608
109
+ )
110
+ row = await cursor.fetchone()
111
+ total_requests = row[0] if row else 0
112
+
113
+ # Requests by endpoint
114
+ cursor = await db.execute(
115
+ f"SELECT endpoint, COUNT(*) AS cnt FROM api_usage{where} GROUP BY endpoint ORDER BY cnt DESC", # noqa: S608
116
+ params,
117
+ )
118
+ endpoint_rows = await cursor.fetchall()
119
+ requests_by_endpoint = [
120
+ EndpointCount(endpoint=r[0], count=r[1]) for r in endpoint_rows
121
+ ]
122
+
123
+ # Requests by verdict -- join with scans table via endpoint pattern
124
+ # We track the scan endpoint's status_code; for verdict we need to
125
+ # correlate with the scans table. A simpler approach: count by status_code
126
+ # group in the usage table. However, the issue specifies "by verdict".
127
+ # We join api_usage rows that hit /api/v1/scan (POST) with scans to get
128
+ # verdicts. For simplicity, we count verdicts from the scans table
129
+ # directly, applying the same date/key filters where possible.
130
+ verdict_conditions: list[str] = []
131
+ verdict_params: list[str] = []
132
+ if start_date is not None:
133
+ verdict_conditions.append("created_at >= ?")
134
+ verdict_params.append(start_date.isoformat())
135
+ if end_date is not None:
136
+ verdict_conditions.append("created_at < date(?, '+1 day')")
137
+ verdict_params.append(end_date.isoformat())
138
+ verdict_where = (
139
+ (" WHERE " + " AND ".join(verdict_conditions)) if verdict_conditions else ""
140
+ )
141
+
142
+ cursor = await db.execute(
143
+ f"SELECT verdict, COUNT(*) AS cnt FROM scans{verdict_where} GROUP BY verdict ORDER BY cnt DESC", # noqa: S608
144
+ verdict_params,
145
+ )
146
+ verdict_rows = await cursor.fetchall()
147
+ requests_by_verdict = [
148
+ VerdictCount(verdict=r[0], count=r[1]) for r in verdict_rows
149
+ ]
150
+
151
+ # Requests over time (daily buckets)
152
+ cursor = await db.execute(
153
+ f"SELECT date(timestamp) AS day, COUNT(*) AS cnt FROM api_usage{where} GROUP BY day ORDER BY day", # noqa: S608
154
+ params,
155
+ )
156
+ time_rows = await cursor.fetchall()
157
+ requests_over_time = [
158
+ DailyBucket(date=r[0], count=r[1]) for r in time_rows
159
+ ]
160
+
161
+ return AnalyticsResponse(
162
+ total_requests=total_requests,
163
+ requests_by_endpoint=requests_by_endpoint,
164
+ requests_by_verdict=requests_by_verdict,
165
+ requests_over_time=requests_over_time,
166
+ )
@@ -0,0 +1,115 @@
1
+ # Copyright (c) 2026 Veritas Aequitas Holdings LLC. All rights reserved.
2
+ """Campaign API endpoints."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ from malwar.api.auth import require_api_key
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Response models
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ class CampaignResponse(BaseModel):
22
+ id: str
23
+ name: str
24
+ description: str
25
+ first_seen: str
26
+ last_seen: str
27
+ attributed_to: str | None = None
28
+ iocs: list[str]
29
+ total_skills_affected: int
30
+ status: str
31
+
32
+
33
+ class CampaignDetailResponse(CampaignResponse):
34
+ signature_count: int = 0
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def _row_to_response(row: dict) -> CampaignResponse:
43
+ iocs_raw = row.get("iocs", "[]")
44
+ iocs = json.loads(iocs_raw) if isinstance(iocs_raw, str) else iocs_raw
45
+ return CampaignResponse(
46
+ id=row["id"],
47
+ name=row["name"],
48
+ description=row["description"],
49
+ first_seen=row["first_seen"],
50
+ last_seen=row["last_seen"],
51
+ attributed_to=row.get("attributed_to"),
52
+ iocs=iocs,
53
+ total_skills_affected=row.get("total_skills_affected", 0),
54
+ status=row["status"],
55
+ )
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Endpoints
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @router.get("/campaigns", response_model=list[CampaignResponse])
64
+ async def list_campaigns(
65
+ _api_key: str = Depends(require_api_key),
66
+ ) -> list[CampaignResponse]:
67
+ """List all active campaigns."""
68
+ from malwar.storage.database import get_db
69
+ from malwar.storage.repositories.campaigns import CampaignRepository
70
+
71
+ db = await get_db()
72
+ repo = CampaignRepository(db)
73
+ rows = await repo.list_active()
74
+ return [_row_to_response(row) for row in rows]
75
+
76
+
77
+ @router.get("/campaigns/{campaign_id}", response_model=CampaignDetailResponse)
78
+ async def get_campaign(
79
+ campaign_id: str,
80
+ _api_key: str = Depends(require_api_key),
81
+ ) -> CampaignDetailResponse:
82
+ """Retrieve a single campaign with details."""
83
+ from malwar.storage.database import get_db
84
+ from malwar.storage.repositories.campaigns import CampaignRepository
85
+
86
+ db = await get_db()
87
+ repo = CampaignRepository(db)
88
+ row = await repo.get(campaign_id)
89
+ if row is None:
90
+ raise HTTPException(
91
+ status_code=404, detail=f"Campaign {campaign_id} not found"
92
+ )
93
+
94
+ iocs_raw = row.get("iocs", "[]")
95
+ iocs = json.loads(iocs_raw) if isinstance(iocs_raw, str) else iocs_raw
96
+
97
+ # Count associated signatures
98
+ cursor = await db.execute(
99
+ "SELECT COUNT(*) FROM signatures WHERE campaign_id = ?", (campaign_id,)
100
+ )
101
+ count_row = await cursor.fetchone()
102
+ signature_count = count_row[0] if count_row else 0
103
+
104
+ return CampaignDetailResponse(
105
+ id=row["id"],
106
+ name=row["name"],
107
+ description=row["description"],
108
+ first_seen=row["first_seen"],
109
+ last_seen=row["last_seen"],
110
+ attributed_to=row.get("attributed_to"),
111
+ iocs=iocs,
112
+ total_skills_affected=row.get("total_skills_affected", 0),
113
+ status=row["status"],
114
+ signature_count=signature_count,
115
+ )