andymal 0.1.0__tar.gz

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 (64) hide show
  1. andymal-0.1.0/.env.example +9 -0
  2. andymal-0.1.0/.gitignore +9 -0
  3. andymal-0.1.0/LICENSE +21 -0
  4. andymal-0.1.0/PKG-INFO +118 -0
  5. andymal-0.1.0/README.md +81 -0
  6. andymal-0.1.0/andymal/__init__.py +1 -0
  7. andymal-0.1.0/andymal/analyzers/__init__.py +12 -0
  8. andymal-0.1.0/andymal/analyzers/base.py +106 -0
  9. andymal-0.1.0/andymal/analyzers/binary.py +114 -0
  10. andymal-0.1.0/andymal/analyzers/context.py +81 -0
  11. andymal-0.1.0/andymal/analyzers/office.py +110 -0
  12. andymal-0.1.0/andymal/analyzers/package.py +265 -0
  13. andymal-0.1.0/andymal/analyzers/pdf.py +99 -0
  14. andymal-0.1.0/andymal/analyzers/script.py +70 -0
  15. andymal-0.1.0/andymal/api/__init__.py +0 -0
  16. andymal-0.1.0/andymal/api/app.py +340 -0
  17. andymal-0.1.0/andymal/api/render.py +100 -0
  18. andymal-0.1.0/andymal/api/store.py +235 -0
  19. andymal-0.1.0/andymal/config.py +51 -0
  20. andymal-0.1.0/andymal/llm.py +78 -0
  21. andymal-0.1.0/andymal/models.py +84 -0
  22. andymal-0.1.0/andymal/prompts/injection.md +33 -0
  23. andymal-0.1.0/andymal/prompts/malware.md +26 -0
  24. andymal-0.1.0/andymal/prompts/package.md +30 -0
  25. andymal-0.1.0/andymal/prompts/system.md +8 -0
  26. andymal-0.1.0/andymal/worker/__init__.py +0 -0
  27. andymal-0.1.0/andymal/worker/pipeline.py +263 -0
  28. andymal-0.1.0/andymal/worker/sample.py +50 -0
  29. andymal-0.1.0/andymal/worker/stage2.py +94 -0
  30. andymal-0.1.0/andymal/worker/static.py +432 -0
  31. andymal-0.1.0/andymal/worker/unpack.py +114 -0
  32. andymal-0.1.0/client/__init__.py +0 -0
  33. andymal-0.1.0/client/andy_cli.py +160 -0
  34. andymal-0.1.0/client/hooks/pretooluse.py +74 -0
  35. andymal-0.1.0/client/hooks/settings.example.json +16 -0
  36. andymal-0.1.0/client/mcp_server.py +97 -0
  37. andymal-0.1.0/deploy/Caddyfile +18 -0
  38. andymal-0.1.0/deploy/Dockerfile +15 -0
  39. andymal-0.1.0/deploy/README.md +49 -0
  40. andymal-0.1.0/deploy/deploy.sh +8 -0
  41. andymal-0.1.0/deploy/docker-compose.yml +73 -0
  42. andymal-0.1.0/deploy/edge/alisv-sites-andymal +76 -0
  43. andymal-0.1.0/deploy/edge/og-front-andymal.conf +56 -0
  44. andymal-0.1.0/deploy/edge/tunnel-t3090-andymal.service +20 -0
  45. andymal-0.1.0/deploy/nginx-andymal.conf +28 -0
  46. andymal-0.1.0/eval/fetch_real.py +153 -0
  47. andymal-0.1.0/eval/manifest.json +15 -0
  48. andymal-0.1.0/eval/run_eval.py +80 -0
  49. andymal-0.1.0/eval/samples/benign/README.md +26 -0
  50. andymal-0.1.0/eval/samples/benign/backup.sh +9 -0
  51. andymal-0.1.0/eval/samples/benign/deploy.py +19 -0
  52. andymal-0.1.0/eval/samples/benign/fetch_prices.py +18 -0
  53. andymal-0.1.0/eval/samples/benign/invoice_email.txt +8 -0
  54. andymal-0.1.0/eval/samples/context/phish_email.txt +8 -0
  55. andymal-0.1.0/eval/samples/injection/README.md +17 -0
  56. andymal-0.1.0/eval/samples/injection/hidden_tags.md +11 -0
  57. andymal-0.1.0/eval/samples/injection/invoice_notice.txt +10 -0
  58. andymal-0.1.0/eval/samples/malicious/install.sh +7 -0
  59. andymal-0.1.0/eval/samples/malicious/postinstall.js +5 -0
  60. andymal-0.1.0/eval/samples/malicious/setup_helper.py +20 -0
  61. andymal-0.1.0/eval/samples/malicious/update.ps1 +6 -0
  62. andymal-0.1.0/pyproject.toml +58 -0
  63. andymal-0.1.0/tests/test_static.py +136 -0
  64. andymal-0.1.0/web/index.html +146 -0
@@ -0,0 +1,9 @@
1
+ ANDY_LLM_BASE_URL=https://api.example.com/v1
2
+ ANDY_LLM_API_KEY=
3
+ ANDY_LLM_MODEL=
4
+ ANDY_DATABASE_URL=sqlite:///./data/andy.db
5
+ ANDY_DATA_DIR=./data
6
+ ANDY_ADMIN_TOKEN=
7
+ ANDY_API_KEYS=
8
+ ANDY_PUBLIC_URL=https://api.andymal.com
9
+ ANDY_SAMPLE_RETENTION_DAYS=30
@@ -0,0 +1,9 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ data/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ eval/results/
9
+ eval/real/
andymal-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Mal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
andymal-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: andymal
3
+ Version: 0.1.0
4
+ Summary: Andy Mal - antimalware for agents. Scan files, packages and instructions with a real-time AI virus analyst before your agent acts on them.
5
+ Project-URL: Homepage, https://andymal.com
6
+ Project-URL: Documentation, https://api.andymal.com/docs
7
+ Author-email: Andy Mal <hello@andymal.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,malware,mcp,prompt-injection,security,supply-chain
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: fastapi>=0.115
18
+ Requires-Dist: httpx[socks]>=0.27
19
+ Requires-Dist: mcp<2,>=1.2
20
+ Requires-Dist: oletools>=0.60
21
+ Requires-Dist: openai>=1.40
22
+ Requires-Dist: pefile>=2023.2.7
23
+ Requires-Dist: psycopg[binary]>=3.1
24
+ Requires-Dist: puremagic>=1.20
25
+ Requires-Dist: pydantic-settings>=2.3
26
+ Requires-Dist: pydantic>=2.7
27
+ Requires-Dist: pypdf>=4.2
28
+ Requires-Dist: python-multipart>=0.0.9
29
+ Requires-Dist: rich>=13.7
30
+ Requires-Dist: socksio>=1.0
31
+ Requires-Dist: sqlalchemy>=2.0
32
+ Requires-Dist: typer>=0.12
33
+ Requires-Dist: uvicorn[standard]>=0.30
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Andy Mal
39
+
40
+ **Antimalware for agents. Your AI virus analyst, on call 24/7.**
41
+
42
+ Agents install packages, clone repos, open attachments, and follow README instructions hundreds of times a day. Signatures are written by human analysts days after a sample appears, and AI-written malware never had one. Andy reverse-engineers every file, package, and instruction in real time, before the agent acts on it.
43
+
44
+ Every scan returns two verdicts:
45
+
46
+ - **payload**: what the file actually does (credential theft, exfiltration, download-and-execute, persistence, obfuscation)
47
+ - **delivery**: whether the agent was socially engineered or prompt-injected into opening it
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ uv venv -p 3.12 && uv pip install -e .
53
+ cp .env.example .env # fill in ANDY_LLM_* and ANDY_API_KEYS
54
+ .venv/bin/andy-server # http://localhost:8080/docs
55
+
56
+ export ANDY_URL=http://localhost:8080 ANDY_API_KEY=dev-key-1
57
+ andy scan eval/samples/malicious/setup_helper.py
58
+ andy scan pypi:requests
59
+ andy scan invoice.pdf --context-file email.txt
60
+ ```
61
+
62
+ Exit codes: 0 clean, 1 suspicious, 2 malicious, 3 unknown/error.
63
+
64
+ ## Get a key
65
+
66
+ ```bash
67
+ curl -X POST https://api.andymal.com/v1/register -F email=you@example.com -F agent=claude-code
68
+ ```
69
+
70
+ Free tier: 50 scans a day per key, 30 per minute burst, unlimited hash lookups. `GET /v1/me` shows usage.
71
+ Admins raise limits with `PATCH /admin/keys/{key}` (`tier`, `daily_limit`, `active`).
72
+
73
+ ## Protocol (hash first)
74
+
75
+ 1. `GET /v1/hash/{sha256}` with `X-Andy-Key`. Hit: cached report, done.
76
+ 2. Miss: `POST /v1/scan` multipart with `file` (or `package=npm:name@ver`, `text=...`, or `sha256=` plus new `context`). `?wait=90` blocks for the verdict.
77
+ 3. `GET /v1/scan/{id}` to poll, `GET /report/{id}` or `/report/{id}.md` for the full report.
78
+
79
+ ## Integrations
80
+
81
+ - CLI: `andy scan ...` (`client/andy_cli.py`)
82
+ - MCP server: `andy-mcp` exposes `andy_scan_file`, `andy_scan_package`, `andy_check_text`, `andy_report`
83
+ - Claude Code hook: `client/hooks/pretooluse.py` checks pip/npm installs and `curl | bash` before they run
84
+
85
+ ## Layout
86
+
87
+ ```
88
+ andymal/api FastAPI service, storage, markdown report
89
+ andymal/worker pipeline, safe unpacker, static heuristics
90
+ andymal/analyzers script, package (npm/pypi), pdf, office, binary, delivery (prompt injection)
91
+ andymal/prompts analyst prompts (untrusted-content framing)
92
+ client/ CLI, MCP server, hooks
93
+ web/ andymal.com landing page
94
+ eval/ labelled samples + scorecard runner
95
+ deploy/ Dockerfile, compose, Caddyfile for t3090
96
+ ```
97
+
98
+ ## Eval
99
+
100
+ ```bash
101
+ .venv/bin/python eval/run_eval.py --static # hand-written set, heuristics only
102
+ .venv/bin/python eval/run_eval.py # hand-written set, with the model
103
+ .venv/bin/python eval/fetch_real.py --npm 12 --pypi 12 --skills 6 --benign 16 --inject 20
104
+ .venv/bin/python eval/run_eval.py eval/real/manifest.json # real samples (DataDog malicious packages + skills, top registry packages, deepset injections)
105
+ ```
106
+
107
+ Results land in `eval/results/<manifest>.json`. Static mode is only a ranking sanity check: on real packages the heuristics alone
108
+ over-flag, and the model is what separates a stealer from a build script. Judge on the full run.
109
+
110
+ ## What Andy looks at that a scanner doesn't
111
+
112
+ - Project auto-execute surfaces: CI workflows, git hooks, Makefiles, `.vscode/tasks.json`, `.claude/settings.json` hooks, `.mcp.json`, `.envrc`, `.pth` files. Code there runs without anyone clicking it.
113
+ - Agent configuration: `SKILL.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, MCP configs and MCP tool descriptions, checked for tool poisoning, tool hijacking ("always use X, no exceptions") and traffic redirection through third-party mirrors.
114
+ - Install hooks that decrypt, decode or compile code at install time, or delete their own source.
115
+
116
+ ## Safety notes
117
+
118
+ Samples are never executed. The unpacker enforces size/count limits and rejects traversal and links. Sample content is passed to the model as delimited untrusted data; the prompts instruct the model to treat embedded instructions as evidence of manipulation. The api container runs read-only and unprivileged. Dynamic analysis, when added, gets its own isolated VM service.
@@ -0,0 +1,81 @@
1
+ # Andy Mal
2
+
3
+ **Antimalware for agents. Your AI virus analyst, on call 24/7.**
4
+
5
+ Agents install packages, clone repos, open attachments, and follow README instructions hundreds of times a day. Signatures are written by human analysts days after a sample appears, and AI-written malware never had one. Andy reverse-engineers every file, package, and instruction in real time, before the agent acts on it.
6
+
7
+ Every scan returns two verdicts:
8
+
9
+ - **payload**: what the file actually does (credential theft, exfiltration, download-and-execute, persistence, obfuscation)
10
+ - **delivery**: whether the agent was socially engineered or prompt-injected into opening it
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ uv venv -p 3.12 && uv pip install -e .
16
+ cp .env.example .env # fill in ANDY_LLM_* and ANDY_API_KEYS
17
+ .venv/bin/andy-server # http://localhost:8080/docs
18
+
19
+ export ANDY_URL=http://localhost:8080 ANDY_API_KEY=dev-key-1
20
+ andy scan eval/samples/malicious/setup_helper.py
21
+ andy scan pypi:requests
22
+ andy scan invoice.pdf --context-file email.txt
23
+ ```
24
+
25
+ Exit codes: 0 clean, 1 suspicious, 2 malicious, 3 unknown/error.
26
+
27
+ ## Get a key
28
+
29
+ ```bash
30
+ curl -X POST https://api.andymal.com/v1/register -F email=you@example.com -F agent=claude-code
31
+ ```
32
+
33
+ Free tier: 50 scans a day per key, 30 per minute burst, unlimited hash lookups. `GET /v1/me` shows usage.
34
+ Admins raise limits with `PATCH /admin/keys/{key}` (`tier`, `daily_limit`, `active`).
35
+
36
+ ## Protocol (hash first)
37
+
38
+ 1. `GET /v1/hash/{sha256}` with `X-Andy-Key`. Hit: cached report, done.
39
+ 2. Miss: `POST /v1/scan` multipart with `file` (or `package=npm:name@ver`, `text=...`, or `sha256=` plus new `context`). `?wait=90` blocks for the verdict.
40
+ 3. `GET /v1/scan/{id}` to poll, `GET /report/{id}` or `/report/{id}.md` for the full report.
41
+
42
+ ## Integrations
43
+
44
+ - CLI: `andy scan ...` (`client/andy_cli.py`)
45
+ - MCP server: `andy-mcp` exposes `andy_scan_file`, `andy_scan_package`, `andy_check_text`, `andy_report`
46
+ - Claude Code hook: `client/hooks/pretooluse.py` checks pip/npm installs and `curl | bash` before they run
47
+
48
+ ## Layout
49
+
50
+ ```
51
+ andymal/api FastAPI service, storage, markdown report
52
+ andymal/worker pipeline, safe unpacker, static heuristics
53
+ andymal/analyzers script, package (npm/pypi), pdf, office, binary, delivery (prompt injection)
54
+ andymal/prompts analyst prompts (untrusted-content framing)
55
+ client/ CLI, MCP server, hooks
56
+ web/ andymal.com landing page
57
+ eval/ labelled samples + scorecard runner
58
+ deploy/ Dockerfile, compose, Caddyfile for t3090
59
+ ```
60
+
61
+ ## Eval
62
+
63
+ ```bash
64
+ .venv/bin/python eval/run_eval.py --static # hand-written set, heuristics only
65
+ .venv/bin/python eval/run_eval.py # hand-written set, with the model
66
+ .venv/bin/python eval/fetch_real.py --npm 12 --pypi 12 --skills 6 --benign 16 --inject 20
67
+ .venv/bin/python eval/run_eval.py eval/real/manifest.json # real samples (DataDog malicious packages + skills, top registry packages, deepset injections)
68
+ ```
69
+
70
+ Results land in `eval/results/<manifest>.json`. Static mode is only a ranking sanity check: on real packages the heuristics alone
71
+ over-flag, and the model is what separates a stealer from a build script. Judge on the full run.
72
+
73
+ ## What Andy looks at that a scanner doesn't
74
+
75
+ - Project auto-execute surfaces: CI workflows, git hooks, Makefiles, `.vscode/tasks.json`, `.claude/settings.json` hooks, `.mcp.json`, `.envrc`, `.pth` files. Code there runs without anyone clicking it.
76
+ - Agent configuration: `SKILL.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, MCP configs and MCP tool descriptions, checked for tool poisoning, tool hijacking ("always use X, no exceptions") and traffic redirection through third-party mirrors.
77
+ - Install hooks that decrypt, decode or compile code at install time, or delete their own source.
78
+
79
+ ## Safety notes
80
+
81
+ Samples are never executed. The unpacker enforces size/count limits and rejects traversal and links. Sample content is passed to the model as delimited untrusted data; the prompts instruct the model to treat embedded instructions as evidence of manipulation. The api container runs read-only and unprivileged. Dynamic analysis, when added, gets its own isolated VM service.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ from .base import Analyzer, AnalyzerResult
2
+ from .binary import BinaryAnalyzer
3
+ from .context import ContextAnalyzer
4
+ from .office import OfficeAnalyzer
5
+ from .package import PackageAnalyzer
6
+ from .pdf import PdfAnalyzer
7
+ from .script import ScriptAnalyzer
8
+
9
+ PAYLOAD_ANALYZERS: list[Analyzer] = [PackageAnalyzer(), ScriptAnalyzer(), PdfAnalyzer(), OfficeAnalyzer(), BinaryAnalyzer()]
10
+ DELIVERY_ANALYZER: Analyzer = ContextAnalyzer()
11
+
12
+ __all__ = ["Analyzer", "AnalyzerResult", "PAYLOAD_ANALYZERS", "DELIVERY_ANALYZER"]
@@ -0,0 +1,106 @@
1
+ """Analyzer contract. Each analyzer looks at the whole Sample and returns verdict pieces."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..llm import chat_json, truncate
10
+ from ..models import Dimension, Finding, IOCs, Verdict
11
+ from ..worker.sample import Sample, SampleFile
12
+
13
+ log = logging.getLogger(__name__)
14
+ PROMPTS = Path(__file__).resolve().parent.parent / "prompts"
15
+ SYSTEM = (PROMPTS / "system.md").read_text()
16
+
17
+
18
+ def prompt(name: str) -> str:
19
+ return (PROMPTS / f"{name}.md").read_text()
20
+
21
+
22
+ @dataclass
23
+ class AnalyzerResult:
24
+ payload: Dimension | None = None
25
+ delivery: Dimension | None = None
26
+ iocs: IOCs = field(default_factory=IOCs)
27
+ texts: list[tuple[str, str]] = field(default_factory=list) # (label, text) for delivery analysis
28
+ notes: list[str] = field(default_factory=list)
29
+ llm_calls: int = 0
30
+
31
+
32
+ class Analyzer:
33
+ name = "base"
34
+
35
+ def wants(self, sample: Sample) -> bool: # pragma: no cover - interface
36
+ return False
37
+
38
+ def run(self, sample: Sample) -> AnalyzerResult: # pragma: no cover - interface
39
+ raise NotImplementedError
40
+
41
+
42
+ def verdict_from_score(score: float) -> Verdict:
43
+ if score >= 0.70:
44
+ return Verdict.malicious
45
+ if score >= 0.35:
46
+ return Verdict.suspicious
47
+ return Verdict.clean
48
+
49
+
50
+ def parse_llm_dimension(data: dict[str, Any], source: str, static_floor: float = 0.0) -> tuple[Dimension, dict[str, Any]]:
51
+ """Turn a model JSON reply into a Dimension, clamping and sanitising."""
52
+ try:
53
+ score = float(data.get("score", 0.0))
54
+ except (TypeError, ValueError):
55
+ score = 0.0
56
+ score = max(0.0, min(1.0, score))
57
+ v = str(data.get("verdict", "")).lower()
58
+ if v in {"clean", "suspicious", "malicious"}:
59
+ # keep model verdict and score consistent
60
+ if v == "malicious":
61
+ score = max(score, 0.70)
62
+ elif v == "suspicious":
63
+ score = max(0.35, min(score, 0.69))
64
+ else:
65
+ score = min(score, 0.34)
66
+ findings: list[Finding] = []
67
+ for f in data.get("findings") or []:
68
+ if not isinstance(f, dict):
69
+ continue
70
+ sev = str(f.get("severity", "info")).lower()
71
+ if sev not in {"info", "low", "medium", "high", "critical"}:
72
+ sev = "info"
73
+ findings.append(Finding(title=str(f.get("title", ""))[:200], detail=str(f.get("detail", ""))[:2000], severity=sev, source=source, location=str(f.get("location", ""))[:200]))
74
+ # static floor: heuristics alone can't clear a file the model called clean, but a model that
75
+ # returned nothing usable shouldn't zero out strong static evidence either
76
+ if static_floor >= 0.5 and score < 0.35 and not findings:
77
+ score = 0.35
78
+ dim = Dimension(verdict=verdict_from_score(score), score=round(score, 3), findings=findings)
79
+ return dim, data
80
+
81
+
82
+ def llm_json(system: str, user: str) -> dict[str, Any]:
83
+ return chat_json(system, user)
84
+
85
+
86
+ def join_files(files: list[SampleFile], budget: int) -> str:
87
+ """Concatenate file texts with headers, spreading a char budget across them."""
88
+ parts: list[str] = []
89
+ if not files:
90
+ return ""
91
+ per = max(2000, budget // len(files))
92
+ for sf in files:
93
+ body = sf.text or ""
94
+ parts.append(f"### FILE: {sf.relpath}\n{truncate(body, per)}\n")
95
+ return "\n".join(parts)
96
+
97
+
98
+ def merge_iocs(*many: IOCs) -> IOCs:
99
+ out = IOCs()
100
+ for i in many:
101
+ for k in ("urls", "domains", "ips", "hashes", "paths"):
102
+ cur = getattr(out, k)
103
+ for v in getattr(i, k):
104
+ if v not in cur:
105
+ cur.append(v)
106
+ return out
@@ -0,0 +1,114 @@
1
+ """PE / ELF / Mach-O static triage. v1 = headers + imports + strings to the model. Deep RE comes later."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from ..config import settings
7
+ from ..llm import truncate
8
+ from ..models import Dimension, Finding
9
+ from ..worker.sample import Sample, SampleFile
10
+ from ..worker.static import strings
11
+ from .base import Analyzer, AnalyzerResult, SYSTEM, llm_json, merge_iocs, parse_llm_dimension, prompt, verdict_from_score
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ SUSPICIOUS_IMPORTS = {
16
+ "VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread", "NtUnmapViewOfSection", "SetWindowsHookExA", "SetWindowsHookExW",
17
+ "GetAsyncKeyState", "CryptUnprotectData", "URLDownloadToFileA", "URLDownloadToFileW", "WinExec", "ShellExecuteA", "ShellExecuteW",
18
+ "InternetOpenUrlA", "InternetOpenUrlW", "HttpSendRequestA", "HttpSendRequestW", "WSAStartup", "IsDebuggerPresent", "AdjustTokenPrivileges",
19
+ "OpenProcessToken", "RegSetValueExA", "RegSetValueExW", "CreateServiceA", "CreateServiceW", "NtQueryInformationProcess", "LoadLibraryA", "GetProcAddress",
20
+ }
21
+
22
+
23
+ def pe_facts(sf: SampleFile) -> tuple[list[str], list[Finding], float]:
24
+ hints: list[str] = []
25
+ findings: list[Finding] = []
26
+ score = 0.0
27
+ try:
28
+ import pefile
29
+
30
+ pe = pefile.PE(str(sf.path), fast_load=True)
31
+ pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"], pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_SECURITY"]])
32
+ secs = []
33
+ for s in pe.sections:
34
+ name = s.Name.rstrip(b"\x00").decode("latin1", "replace")
35
+ ent = s.get_entropy()
36
+ secs.append(f"{name}(entropy {ent:.1f}, raw {s.SizeOfRawData})")
37
+ if name.lower() in {"upx0", "upx1", ".upx", ".aspack", ".themida", ".vmp0", ".vmp1", ".petite", ".mpress1"}:
38
+ findings.append(Finding(title="Packer section name", detail=name, severity="medium", source="static"))
39
+ score += 0.2
40
+ if ent > 7.2 and s.SizeOfRawData > 4096:
41
+ score += 0.1
42
+ hints.append(f"- sections: {', '.join(secs)}")
43
+ imports: list[str] = []
44
+ sus: list[str] = []
45
+ for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []):
46
+ dll = entry.dll.decode("latin1", "replace")
47
+ names = [i.name.decode("latin1", "replace") for i in entry.imports if i.name]
48
+ imports.append(f"{dll}: {', '.join(names[:40])}")
49
+ sus.extend(n for n in names if n in SUSPICIOUS_IMPORTS)
50
+ if len(imports) <= 2 and sum(len(i) for i in imports) < 200:
51
+ findings.append(Finding(title="Tiny import table", detail="Very few imports: likely packed, or resolves APIs dynamically", severity="medium", source="static"))
52
+ score += 0.15
53
+ if sus:
54
+ findings.append(Finding(title="Suspicious API imports", detail=", ".join(sorted(set(sus))), severity="high", source="static"))
55
+ score += min(0.3, 0.05 * len(set(sus)))
56
+ hints.append("- imports:\n" + "\n".join(" " + i for i in imports[:30]))
57
+ signed = hasattr(pe, "DIRECTORY_ENTRY_SECURITY") or bool(pe.OPTIONAL_HEADER.DATA_DIRECTORY[4].VirtualAddress)
58
+ hints.append(f"- authenticode signature present: {signed} (not verified)")
59
+ if not signed:
60
+ score += 0.05
61
+ hints.append(f"- compile timestamp: {pe.FILE_HEADER.TimeDateStamp}, machine: {hex(pe.FILE_HEADER.Machine)}")
62
+ except Exception as e: # noqa: BLE001
63
+ hints.append(f"- pefile parse failed: {e}")
64
+ return hints, findings, min(score, 1.0)
65
+
66
+
67
+ class BinaryAnalyzer(Analyzer):
68
+ name = "binary"
69
+
70
+ def wants(self, sample: Sample) -> bool:
71
+ return any(f.category == "binary" for f in sample.files)
72
+
73
+ def run(self, sample: Sample) -> AnalyzerResult:
74
+ res = AnalyzerResult()
75
+ bins = sorted((f for f in sample.files if f.category == "binary"), key=lambda f: -f.static.score)[:3]
76
+ worst = Dimension()
77
+ for sf in bins:
78
+ hints = list(sf.static.hints)
79
+ findings = list(sf.static.findings)
80
+ static_score = sf.static.score
81
+ if sf.file_type == "pe":
82
+ h, f, s = pe_facts(sf)
83
+ hints.extend(h)
84
+ findings.extend(f)
85
+ static_score = min(1.0, static_score + s)
86
+ raw = sf.path.read_bytes()
87
+ text = strings(raw, min_len=6, limit=120_000)
88
+ user = prompt("malware").format(
89
+ filename=sf.relpath,
90
+ file_type=sf.file_type,
91
+ size=sf.size,
92
+ static_hints="\n".join(hints) or "- none",
93
+ content="Printable strings extracted from the binary (imports/sections listed above):\n" + truncate(text, settings.llm_max_content_chars // 2),
94
+ )
95
+ try:
96
+ data = llm_json(SYSTEM, user)
97
+ res.llm_calls += 1
98
+ dim, data = parse_llm_dimension(data, "andy", static_floor=static_score)
99
+ res.notes.append(f"{sf.relpath}: {data.get('intent', '')}")
100
+ res.notes.append(f"summary: {data.get('summary', '')}")
101
+ except Exception as e: # noqa: BLE001
102
+ log.warning("binary LLM failed: %s", e)
103
+ dim = Dimension(verdict=verdict_from_score(static_score), score=round(static_score, 3))
104
+ res.notes.append(f"model unavailable ({type(e).__name__}); heuristic verdict only")
105
+ dim.findings.extend(findings[:15])
106
+ dim.findings.append(Finding(title="Static triage only", detail="v1 binary analysis is static (headers, imports, strings). Dynamic/decompiled analysis not yet run.", severity="info", source="andy"))
107
+ if dim.score > worst.score:
108
+ worst = dim
109
+ res.iocs = merge_iocs(res.iocs, sf.static.iocs)
110
+ # unknown binaries that look clean still shouldn't be blindly allowed at v1
111
+ if worst.verdict.value == "clean" and worst.score < 0.2:
112
+ worst.score = max(worst.score, 0.2)
113
+ res.payload = worst
114
+ return res
@@ -0,0 +1,81 @@
1
+ """Delivery analysis: was the agent socially engineered / prompt-injected? Runs last, one LLM call."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+
7
+ from ..config import settings
8
+ from ..llm import truncate
9
+ from ..models import Dimension
10
+ from ..worker.sample import Sample
11
+ from ..worker.static import STRONG_INJECTION, extract_iocs, hints_from, scan_injection
12
+
13
+ # files an agent reads as instructions, in priority order
14
+ INSTRUCTION_FILES = re.compile(r"(^|/)(readme|skill|agents?\.md|claude\.md|\.?cursorrules|copilot-instructions|instructions|install|setup|contributing|usage|getting[-_]?started|prompt|system|\.clinerules|\.windsurfrules|gemini\.md|\.?mcp\.json|claude_desktop_config\.json|plugin\.json|manifest\.json|\.claude/|\.cursor/|commands/|agents/|rules/)[^/]*$", re.IGNORECASE)
15
+ MAX_TEXT_FILES = 8
16
+ from .base import Analyzer, AnalyzerResult, SYSTEM, llm_json, parse_llm_dimension, prompt, verdict_from_score
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ class ContextAnalyzer(Analyzer):
22
+ name = "delivery"
23
+
24
+ def wants(self, sample: Sample) -> bool:
25
+ return bool(sample.context.strip()) or bool(sample.extracted_texts) or any(f.category == "document" and f.text for f in sample.files)
26
+
27
+ def run(self, sample: Sample) -> AnalyzerResult:
28
+ res = AnalyzerResult()
29
+ texts = list(sample.extracted_texts)
30
+ seen = {label for label, _ in texts}
31
+ docs = [f for f in sample.files if f.category in {"document", "manifest"} and f.text and f.relpath not in seen]
32
+ # MCP tool descriptions extracted from server code are instructions too (tool poisoning)
33
+ for f in sample.files:
34
+ if f.category == "script" and f.static.extracted_text.startswith("[tool description]") and f.relpath not in seen:
35
+ texts.append((f"{f.relpath} (tool descriptions)", f.static.extracted_text))
36
+ seen.add(f.relpath)
37
+ single = len(sample.files) <= 1
38
+ # in a multi-file sample only instruction-like files matter for delivery; rank them by static injection score
39
+ ranked = []
40
+ for f in docs:
41
+ if not single and not INSTRUCTION_FILES.search(f.relpath):
42
+ continue
43
+ _, hits, s = scan_injection(f.text[:200_000])
44
+ ranked.append((s, f.relpath.count("/"), f))
45
+ ranked.sort(key=lambda x: (-x[0], x[1]))
46
+ for _, _, f in ranked[:MAX_TEXT_FILES]:
47
+ texts.append((f.relpath, f.text))
48
+ skipped = len(docs) - len(ranked[:MAX_TEXT_FILES]) if not single else 0
49
+ if skipped:
50
+ res.notes.append(f"{skipped} document files not used for delivery analysis (not instruction files)")
51
+ texts = texts[: MAX_TEXT_FILES + 4]
52
+ budget = settings.llm_max_content_chars
53
+ per = max(2000, budget // max(1, len(texts))) if texts else budget
54
+ body = "\n\n".join(f"### {label}\n{truncate(t, per)}" for label, t in texts) or "(no text extracted from the file)"
55
+ body = truncate(body, budget)
56
+ context = truncate(sample.context, budget // 2) if sample.context.strip() else "(no context supplied by the client)"
57
+
58
+ f_all, hits, static_score = scan_injection(sample.context + "\n\n" + "\n\n".join(t[:200_000] for _, t in texts))
59
+ structural = {"hidden_text", "tool_call_spoof", "ignore_previous", "system_impersonation"} & set(hits)
60
+ res.iocs = extract_iocs(sample.context)
61
+ hints = hints_from(f_all, hits)
62
+ user = prompt("injection").format(context=context, content=body, static_hints="\n".join(hints))
63
+ try:
64
+ data = llm_json(SYSTEM, user)
65
+ res.llm_calls = 1
66
+ dim, data = parse_llm_dimension(data, "andy", static_floor=static_score if structural else 0.0)
67
+ res.notes.append(f"intended agent action: {data.get('intended_agent_action', '')}")
68
+ res.notes.append(f"summary: {data.get('summary', '')}")
69
+ if data.get("techniques"):
70
+ res.notes.append(f"techniques: {data['techniques']}")
71
+ except Exception as e: # noqa: BLE001
72
+ log.warning("delivery LLM failed: %s", e)
73
+ dim = Dimension(verdict=verdict_from_score(static_score), score=round(static_score, 3))
74
+ res.notes.append(f"model unavailable ({type(e).__name__}); heuristic verdict only")
75
+ dim.findings.extend(f_all[:15])
76
+ # strong static signals (hidden text, forged tool calls) should not be talked down to clean
77
+ if structural and static_score >= 0.7 and dim.score < 0.5:
78
+ dim.score = 0.5
79
+ dim.verdict = verdict_from_score(dim.score)
80
+ res.delivery = dim
81
+ return res