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.
- malwar/__init__.py +25 -0
- malwar/__main__.py +6 -0
- malwar/api/__init__.py +1 -0
- malwar/api/app.py +90 -0
- malwar/api/auth.py +35 -0
- malwar/api/middleware.py +181 -0
- malwar/api/routes/__init__.py +1 -0
- malwar/api/routes/analytics.py +166 -0
- malwar/api/routes/campaigns.py +115 -0
- malwar/api/routes/export.py +136 -0
- malwar/api/routes/feed.py +229 -0
- malwar/api/routes/health.py +38 -0
- malwar/api/routes/ingest.py +76 -0
- malwar/api/routes/reports.py +205 -0
- malwar/api/routes/scan.py +341 -0
- malwar/api/routes/signatures.py +192 -0
- malwar/cli/__init__.py +1 -0
- malwar/cli/app.py +439 -0
- malwar/cli/commands/__init__.py +1 -0
- malwar/cli/commands/db.py +103 -0
- malwar/cli/commands/export.py +99 -0
- malwar/cli/commands/ingest.py +152 -0
- malwar/cli/formatters/__init__.py +1 -0
- malwar/cli/formatters/console.py +102 -0
- malwar/cli/formatters/json_fmt.py +30 -0
- malwar/cli/formatters/sarif.py +88 -0
- malwar/core/__init__.py +1 -0
- malwar/core/config.py +88 -0
- malwar/core/constants.py +54 -0
- malwar/core/exceptions.py +38 -0
- malwar/core/logging.py +56 -0
- malwar/detectors/__init__.py +1 -0
- malwar/detectors/llm_analyzer/__init__.py +1 -0
- malwar/detectors/llm_analyzer/detector.py +137 -0
- malwar/detectors/llm_analyzer/parser.py +189 -0
- malwar/detectors/llm_analyzer/prompts.py +95 -0
- malwar/detectors/rule_engine/__init__.py +1 -0
- malwar/detectors/rule_engine/base_rule.py +26 -0
- malwar/detectors/rule_engine/detector.py +63 -0
- malwar/detectors/rule_engine/registry.py +43 -0
- malwar/detectors/rule_engine/rules/__init__.py +1 -0
- malwar/detectors/rule_engine/rules/agent_hijacking.py +83 -0
- malwar/detectors/rule_engine/rules/credential_exposure.py +102 -0
- malwar/detectors/rule_engine/rules/env_harvesting.py +93 -0
- malwar/detectors/rule_engine/rules/exfiltration.py +150 -0
- malwar/detectors/rule_engine/rules/known_malware.py +117 -0
- malwar/detectors/rule_engine/rules/multi_step.py +93 -0
- malwar/detectors/rule_engine/rules/obfuscation.py +138 -0
- malwar/detectors/rule_engine/rules/persistence.py +163 -0
- malwar/detectors/rule_engine/rules/prompt_injection.py +136 -0
- malwar/detectors/rule_engine/rules/social_engineering.py +193 -0
- malwar/detectors/rule_engine/rules/steganography.py +169 -0
- malwar/detectors/rule_engine/rules/supply_chain.py +168 -0
- malwar/detectors/rule_engine/rules/suspicious_commands.py +180 -0
- malwar/detectors/threat_intel/__init__.py +1 -0
- malwar/detectors/threat_intel/detector.py +55 -0
- malwar/detectors/threat_intel/matcher.py +299 -0
- malwar/detectors/url_crawler/__init__.py +6 -0
- malwar/detectors/url_crawler/analyzer.py +247 -0
- malwar/detectors/url_crawler/detector.py +144 -0
- malwar/detectors/url_crawler/extractor.py +113 -0
- malwar/detectors/url_crawler/fetcher.py +222 -0
- malwar/detectors/url_crawler/reputation.py +138 -0
- malwar/export/__init__.py +24 -0
- malwar/export/stix.py +330 -0
- malwar/export/taxii.py +139 -0
- malwar/ingestion/__init__.py +27 -0
- malwar/ingestion/importer.py +238 -0
- malwar/ingestion/schema.py +63 -0
- malwar/ingestion/sources.py +479 -0
- malwar/integrations/__init__.py +16 -0
- malwar/integrations/exceptions.py +40 -0
- malwar/integrations/langchain.py +419 -0
- malwar/models/__init__.py +17 -0
- malwar/models/finding.py +39 -0
- malwar/models/report.py +27 -0
- malwar/models/sarif.py +70 -0
- malwar/models/scan.py +78 -0
- malwar/models/signature.py +43 -0
- malwar/models/skill.py +57 -0
- malwar/notifications/__init__.py +6 -0
- malwar/notifications/webhook.py +140 -0
- malwar/parsers/__init__.py +13 -0
- malwar/parsers/markdown_parser.py +125 -0
- malwar/parsers/skill_parser.py +121 -0
- malwar/py.typed +0 -0
- malwar/scanner/__init__.py +1 -0
- malwar/scanner/base.py +36 -0
- malwar/scanner/context.py +48 -0
- malwar/scanner/pipeline.py +144 -0
- malwar/scanner/severity.py +35 -0
- malwar/sdk.py +269 -0
- malwar/storage/__init__.py +22 -0
- malwar/storage/database.py +67 -0
- malwar/storage/migrations.py +532 -0
- malwar/storage/repositories/__init__.py +16 -0
- malwar/storage/repositories/campaigns.py +33 -0
- malwar/storage/repositories/findings.py +74 -0
- malwar/storage/repositories/publishers.py +44 -0
- malwar/storage/repositories/scans.py +132 -0
- malwar/storage/repositories/signatures.py +133 -0
- malwar-0.2.1.dist-info/METADATA +209 -0
- malwar-0.2.1.dist-info/RECORD +106 -0
- malwar-0.2.1.dist-info/WHEEL +4 -0
- malwar-0.2.1.dist-info/entry_points.txt +2 -0
- 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
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
|
malwar/api/middleware.py
ADDED
|
@@ -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
|
+
)
|