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.
- andymal-0.1.0/.env.example +9 -0
- andymal-0.1.0/.gitignore +9 -0
- andymal-0.1.0/LICENSE +21 -0
- andymal-0.1.0/PKG-INFO +118 -0
- andymal-0.1.0/README.md +81 -0
- andymal-0.1.0/andymal/__init__.py +1 -0
- andymal-0.1.0/andymal/analyzers/__init__.py +12 -0
- andymal-0.1.0/andymal/analyzers/base.py +106 -0
- andymal-0.1.0/andymal/analyzers/binary.py +114 -0
- andymal-0.1.0/andymal/analyzers/context.py +81 -0
- andymal-0.1.0/andymal/analyzers/office.py +110 -0
- andymal-0.1.0/andymal/analyzers/package.py +265 -0
- andymal-0.1.0/andymal/analyzers/pdf.py +99 -0
- andymal-0.1.0/andymal/analyzers/script.py +70 -0
- andymal-0.1.0/andymal/api/__init__.py +0 -0
- andymal-0.1.0/andymal/api/app.py +340 -0
- andymal-0.1.0/andymal/api/render.py +100 -0
- andymal-0.1.0/andymal/api/store.py +235 -0
- andymal-0.1.0/andymal/config.py +51 -0
- andymal-0.1.0/andymal/llm.py +78 -0
- andymal-0.1.0/andymal/models.py +84 -0
- andymal-0.1.0/andymal/prompts/injection.md +33 -0
- andymal-0.1.0/andymal/prompts/malware.md +26 -0
- andymal-0.1.0/andymal/prompts/package.md +30 -0
- andymal-0.1.0/andymal/prompts/system.md +8 -0
- andymal-0.1.0/andymal/worker/__init__.py +0 -0
- andymal-0.1.0/andymal/worker/pipeline.py +263 -0
- andymal-0.1.0/andymal/worker/sample.py +50 -0
- andymal-0.1.0/andymal/worker/stage2.py +94 -0
- andymal-0.1.0/andymal/worker/static.py +432 -0
- andymal-0.1.0/andymal/worker/unpack.py +114 -0
- andymal-0.1.0/client/__init__.py +0 -0
- andymal-0.1.0/client/andy_cli.py +160 -0
- andymal-0.1.0/client/hooks/pretooluse.py +74 -0
- andymal-0.1.0/client/hooks/settings.example.json +16 -0
- andymal-0.1.0/client/mcp_server.py +97 -0
- andymal-0.1.0/deploy/Caddyfile +18 -0
- andymal-0.1.0/deploy/Dockerfile +15 -0
- andymal-0.1.0/deploy/README.md +49 -0
- andymal-0.1.0/deploy/deploy.sh +8 -0
- andymal-0.1.0/deploy/docker-compose.yml +73 -0
- andymal-0.1.0/deploy/edge/alisv-sites-andymal +76 -0
- andymal-0.1.0/deploy/edge/og-front-andymal.conf +56 -0
- andymal-0.1.0/deploy/edge/tunnel-t3090-andymal.service +20 -0
- andymal-0.1.0/deploy/nginx-andymal.conf +28 -0
- andymal-0.1.0/eval/fetch_real.py +153 -0
- andymal-0.1.0/eval/manifest.json +15 -0
- andymal-0.1.0/eval/run_eval.py +80 -0
- andymal-0.1.0/eval/samples/benign/README.md +26 -0
- andymal-0.1.0/eval/samples/benign/backup.sh +9 -0
- andymal-0.1.0/eval/samples/benign/deploy.py +19 -0
- andymal-0.1.0/eval/samples/benign/fetch_prices.py +18 -0
- andymal-0.1.0/eval/samples/benign/invoice_email.txt +8 -0
- andymal-0.1.0/eval/samples/context/phish_email.txt +8 -0
- andymal-0.1.0/eval/samples/injection/README.md +17 -0
- andymal-0.1.0/eval/samples/injection/hidden_tags.md +11 -0
- andymal-0.1.0/eval/samples/injection/invoice_notice.txt +10 -0
- andymal-0.1.0/eval/samples/malicious/install.sh +7 -0
- andymal-0.1.0/eval/samples/malicious/postinstall.js +5 -0
- andymal-0.1.0/eval/samples/malicious/setup_helper.py +20 -0
- andymal-0.1.0/eval/samples/malicious/update.ps1 +6 -0
- andymal-0.1.0/pyproject.toml +58 -0
- andymal-0.1.0/tests/test_static.py +136 -0
- andymal-0.1.0/web/index.html +146 -0
andymal-0.1.0/.gitignore
ADDED
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.
|
andymal-0.1.0/README.md
ADDED
|
@@ -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
|