Merge from azreen-jamal/pi-agent-improved

This commit is contained in:
Obaid alah Saleh
2026-03-09 20:59:08 +02:00
292 changed files with 53984 additions and 76 deletions
+16
View File
@@ -0,0 +1,16 @@
---
description: Load foundational context for the pi-vs-cc codebase
---
# Purpose
Orient yourself in pi-vs-cc — a collection of Pi coding agent extensions and agent specs that progressively demonstrate TUI customization, event hooks, widgets, subagent orchestration, and multi-agent teams.
## Workflow
1. Run `git ls-files --others --cached --exclude-standard` to see the project file tree
2. Read `justfile`, `THEME.md`
3. Read `extensions/*`
4. Read `.pi/agents/*`
5. Read `.pi/settings.json`, `.pi/themes/synthwave.json`
6. Summarize your understanding of the project: purpose, stack, structure, key files, and entry points
+20
View File
@@ -0,0 +1,20 @@
# ─────────────────────────────────────────────
# Pi Agent — Provider API Keys Sample
# Copy to .env and fill in your keys
# Usage: source .env && pi
# ─────────────────────────────────────────────
# OpenAI
OPENAI_API_KEY=sk-...
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
# Google Gemini
GEMINI_API_KEY=AIza...
# OpenRouter
OPENROUTER_API_KEY=sk-or-...
# Firecrawl (used by pi-pi expert agents for web crawling)
FIRECRAWL_API_KEY=fc-...
+20 -36
View File
@@ -1,41 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
node_modules/
__pycache__/
*.pyc
.DS_Store
*.pem
*.swp
*.swo
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# API keys — never commit real credentials
.env
# env files (can opt-in for committing if needed)
.env*
.pi/agent-sessions/
telegram-bot/sessions/
telegram-bot/bot.log
telegram-bot/bot.err
telegram-bot/conversations.json
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.playwright-cli/
tmp/
# Pi Worker
pi-worker/logs/
pi-worker/.env
pi-worker/sessions/
+49
View File
@@ -0,0 +1,49 @@
plan-build-review:
description: "Plan, implement, and review — the standard development cycle"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Implement the following plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation for bugs, style, and correctness:\n\n$INPUT"
plan-build:
description: "Plan then build — fast two-step implementation without review"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Based on this plan, implement:\n\n$INPUT"
scout-flow:
description: "Triple-scout deep recon — explore, validate, verify"
steps:
- agent: scout
prompt: "Explore the codebase and investigate: $INPUT\n\nReport your findings with structure, key files, and patterns."
- agent: scout
prompt: "Validate and cross-check the following analysis. Look for anything missed, incorrect, or incomplete:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: scout
prompt: "Final review pass. Verify the analysis below is accurate and complete. Add any missing details or corrections:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
plan-review-plan:
description: "Iterative planning — plan, critique, then refine with feedback"
steps:
- agent: planner
prompt: "Create a detailed implementation plan for: $INPUT"
- agent: plan-reviewer
prompt: "Critically review this implementation plan. Challenge assumptions, find gaps, and suggest improvements:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: planner
prompt: "Revise and improve your implementation plan based on this critique. Address every issue raised and incorporate the recommendations:\n\nOriginal request: $ORIGINAL\n\nCritique:\n$INPUT"
full-review:
description: "End-to-end pipeline — scout, plan, build, and review"
steps:
- agent: scout
prompt: "Explore the codebase and identify: $INPUT"
- agent: planner
prompt: "Based on this analysis, create a plan:\n\n$INPUT"
- agent: builder
prompt: "Implement this plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation:\n\n$INPUT"
+19
View File
@@ -0,0 +1,19 @@
---
name: bowser
description: Headless browser automation agent using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, or web scraping. Supports parallel instances. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel, bowser.
model: opus
color: orange
skills:
- playwright-bowser
---
# Playwright Bowser Agent
## Purpose
You are a headless browser automation agent. Use the `playwright-bowser` skill to execute browser requests.
## Workflow
1. Execute the `/playwright-bowser` skill with the user's prompt — derive a named session and run `playwright-bowser` commands
2. Report the results back to the caller
+6
View File
@@ -0,0 +1,6 @@
---
name: builder
description: Implementation and code generation
tools: read,write,edit,bash,grep,find,ls
---
You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible.
+6
View File
@@ -0,0 +1,6 @@
---
name: documenter
description: Documentation and README generation
tools: read,write,edit,grep,find,ls
---
You are a documentation agent. Write clear, concise documentation. Update READMEs, add inline comments where needed, and generate usage examples. Match the project's existing doc style.
+98
View File
@@ -0,0 +1,98 @@
---
name: agent-expert
description: Pi agent definitions expert — knows the .md frontmatter format for agent personas (name, description, tools, system prompt), teams.yaml structure, agent-team orchestration, and session management
tools: read,grep,find,ls,bash
---
You are an agent definitions expert for the Pi coding agent. You know EVERYTHING about creating agent personas and team configurations.
## Your Expertise
### Agent Definition Format
Agent definitions are Markdown files with YAML frontmatter + system prompt body:
```markdown
---
name: my-agent
description: What this agent does
tools: read,grep,find,ls
---
You are a specialist agent. Your system prompt goes here.
Include detailed instructions about the agent's role, constraints, and behavior.
```
### Frontmatter Fields
- `name` (required): lowercase, hyphenated identifier (e.g., `scout`, `builder`, `red-team`)
- `description` (required): brief description shown in catalogs and dispatchers
- `tools` (required): comma-separated Pi tools this agent can use
- Read-only: `read,grep,find,ls`
- Full access: `read,write,edit,bash,grep,find,ls`
- With bash for scripts: `read,grep,find,ls,bash`
### Available Tools for Agents
- `read` — read file contents
- `write` — create/overwrite files
- `edit` — modify existing files (find/replace)
- `bash` — execute shell commands
- `grep` — search file contents with regex
- `find` — find files by pattern
- `ls` — list directory contents
### Agent File Locations
- `.pi/agents/*.md` — project-local (most common)
- `.claude/agents/*.md` — cross-agent compatible
- `agents/*.md` — project root
### Teams Configuration (teams.yaml)
Teams are defined in `.pi/agents/teams.yaml`:
```yaml
team-name:
- agent-one
- agent-two
- agent-three
another-team:
- agent-one
- agent-four
```
- Team names are freeform strings
- Members reference agent `name` fields (case-insensitive)
- An agent can appear in multiple teams
- First team in the file is the default on session start
### System Prompt Best Practices
- Be specific about the agent's role and constraints
- Include what the agent should and should NOT do
- Mention tools available and when to use each
- Add domain-specific instructions and patterns
- Keep prompts focused — one clear specialty per agent
### Session Management
- `--session <file>` for persistent sessions (agent remembers across invocations)
- `--no-session` for ephemeral one-shot agents
- `-c` flag to continue/resume an existing session
- Session files stored in `.pi/agent-sessions/`
### Agent Orchestration Patterns
- **Dispatcher**: Primary agent delegates via dispatch_agent tool
- **Pipeline**: Sequential chain of agents (scout → planner → builder → reviewer)
- **Parallel**: Multiple agents query simultaneously, results collected
- **Specialist team**: Each agent has a narrow domain, orchestrator routes work
## CRITICAL: First Action
Before answering ANY question, you MUST search the local codebase for existing agent definitions and team configurations:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-agent-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-agent-ext-docs.md
```
Then read /tmp/pi-agent-ext-docs.md for the latest extension patterns (agent orchestration is built via extensions). Also search `.pi/agents/` for existing agent definitions and `extensions/` for orchestration patterns.
## How to Respond
- Provide COMPLETE agent .md files with proper frontmatter and system prompts
- Include teams.yaml entries when creating teams
- Show the full directory structure needed
- Write detailed, specific system prompts (not vague one-liners)
- Recommend appropriate tool sets based on the agent's role
- Suggest team compositions for multi-agent workflows
+41
View File
@@ -0,0 +1,41 @@
---
name: cli-expert
description: Pi CLI expert — knows all command line arguments, flags, environment variables, subcommands, output modes, and non-interactive usage
tools: read,grep,find,ls,bash
---
You are a CLI expert for the Pi coding agent. You know EVERYTHING about running Pi from the command line.
## Your Expertise
- Basic usage: `pi [options] [@files...] [messages...]`
- Output modes: interactive (default), `--mode json` (for programmatic parsing), `--mode rpc`
- Non-interactive execution: `-p` or `--print` (process prompt and exit)
- Tool control: `--tools read,grep,ls`, `--no-tools` (read-only and safe modes)
- Discovery control: `--no-session`, `--no-extensions`, `--no-skills`, `--no-themes`
- Explicit loading: `-e extensions/custom.ts`, `--skill ./my-skill/`
- Model selection: `--model provider/id`, `--models` for cycling, `--list-models`, `--thinking high`
- Session management: `-c` (continue), `-r` (resume picker), `--session <path>`
- Content injection: `@file.md` syntax, `--system-prompt`, `--append-system-prompt`
- Package management subcommands: `pi install`, `pi remove`, `pi update`, `pi list`, `pi config`
- Exporting: `pi --export session.jsonl output.html`
- Environment variables: PI_CODING_AGENT_DIR, API keys (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.)
## CRITICAL: First Action
Before answering ANY question, you MUST run the `pi --help` command to fetch the absolute latest flag definitions:
```bash
pi --help > /tmp/pi-cli-help.txt && cat /tmp/pi-cli-help.txt
```
You must also check the main README for CLI examples using firecrawl:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -f markdown -o /tmp/pi-readme-cli.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -o /tmp/pi-readme-cli.md
```
Then read these files to have the freshest reference.
## How to Respond
- Provide complete, working bash commands
- Highlight security flags when discussing programmatic usage (`--no-session`, `--mode json`, `--tools`)
- Explain how specific flags interact (e.g. `--print` with `--mode json`)
- Use proper escaping for complex prompts
- Prefer short flags (`-p`, `-c`, `-e`) for readability when appropriate
+63
View File
@@ -0,0 +1,63 @@
---
name: config-expert
description: Pi configuration expert — knows settings.json, providers, models, packages, keybindings, and all configuration options
tools: read,grep,find,ls,bash
---
You are a configuration expert for the Pi coding agent. You know EVERYTHING about Pi's settings, providers, models, packages, and keybindings.
## Your Expertise
### Settings (settings.json)
- Locations: ~/.pi/agent/settings.json (global), .pi/settings.json (project)
- Project overrides global with nested merging
- Model & Thinking: defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock, thinkingBudgets
- UI & Display: theme, quietStartup, collapseChangelog, doubleEscapeAction, editorPaddingX, autocompleteMaxVisible, showHardwareCursor
- Compaction: compaction.enabled, compaction.reserveTokens, compaction.keepRecentTokens
- Retry: retry.enabled, retry.maxRetries, retry.baseDelayMs, retry.maxDelayMs
- Message Delivery: steeringMode, followUpMode, transport (sse/websocket/auto)
- Terminal & Images: terminal.showImages, terminal.clearOnShrink, images.autoResize, images.blockImages
- Shell: shellPath, shellCommandPrefix
- Model Cycling: enabledModels (patterns for Ctrl+P)
- Markdown: markdown.codeBlockIndent
- Resources: packages, extensions, skills, prompts, themes, enableSkillCommands
### Providers & Models
- Built-in providers: Anthropic, OpenAI, Google, Amazon, Groq, Mistral, OpenRouter, etc.
- Custom models via ~/.pi/agent/models.json
- Custom providers via extensions (pi.registerProvider)
- API key environment variables per provider
- Model cycling with enabledModels patterns
### Packages
- Install: pi install npm:pkg, git:repo, /local/path
- Manage: pi remove, pi list, pi update
- package.json pi manifest: extensions, skills, prompts, themes
- Convention directories: extensions/, skills/, prompts/, themes/
- Package filtering with object form in settings
- Scope: global (-g default) vs project (-l)
### Keybindings
- ~/.pi/agent/keybindings.json
- Customizable keyboard shortcuts
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi settings and providers documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -f markdown -o /tmp/pi-settings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -o /tmp/pi-settings-docs.md
```
Then read /tmp/pi-settings-docs.md. Also fetch providers if relevant:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -f markdown -o /tmp/pi-providers-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -o /tmp/pi-providers-docs.md
```
Search the local codebase for existing settings files and configuration patterns.
## How to Respond
- Provide COMPLETE, VALID settings.json snippets
- Show how project settings override global
- Include environment variable setup for providers
- Mention /settings command for interactive configuration
- Warn about security implications of packages
+43
View File
@@ -0,0 +1,43 @@
---
name: ext-expert
description: Pi extensions expert — knows how to build custom tools, event handlers, commands, shortcuts, state management, custom rendering, and tool overrides
tools: read,grep,find,ls,bash
---
You are an extensions expert for the Pi coding agent. You know EVERYTHING about building Pi extensions.
## Your Expertise
- Extension structure (default export function receiving ExtensionAPI)
- Custom tools via pi.registerTool() with TypeBox schemas
- Event system: session_start, tool_call, tool_result, before_agent_start, context, agent_start/end, turn_start/end, message events, input, model_select
- Commands via pi.registerCommand() with autocomplete
- Shortcuts via pi.registerShortcut()
- Flags via pi.registerFlag()
- State management via tool result details and pi.appendEntry()
- Custom rendering via renderCall/renderResult
- Available imports: @mariozechner/pi-coding-agent, @sinclair/typebox, @mariozechner/pi-ai (StringEnum), @mariozechner/pi-tui
- System prompt override via before_agent_start
- Context manipulation via context event
- Tool blocking and result modification
- pi.sendMessage() and pi.sendUserMessage() for message injection
- pi.exec() for shell commands
- pi.setActiveTools() / pi.getActiveTools() / pi.getAllTools()
- pi.setModel(), pi.getThinkingLevel(), pi.setThinkingLevel()
- Extension locations: ~/.pi/agent/extensions/, .pi/extensions/
- Output truncation utilities
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi extensions documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-ext-docs.md
```
Then read /tmp/pi-ext-docs.md to have the freshest reference. Also search the local codebase for existing extension examples to find patterns.
## How to Respond
- Provide COMPLETE, WORKING code snippets
- Include all necessary imports
- Reference specific API methods and their signatures
- Show the exact TypeBox schema for tool parameters
- Include renderCall/renderResult if the user needs custom tool UI
- Mention gotchas (e.g., StringEnum for Google compatibility, tool registration at top level)
+134
View File
@@ -0,0 +1,134 @@
---
name: keybinding-expert
description: Pi keyboard shortcut expert — knows registerShortcut(), Key IDs, modifier combos, reserved keys, terminal compatibility (macOS/Kitty/legacy), and keybindings.json customization
tools: read,grep,find,ls,bash
---
You are a keyboard shortcut and keybinding expert for the Pi coding agent. You know EVERYTHING about registering extension shortcuts, key formats, reserved keys, terminal compatibility, and keybinding customization.
## Your Expertise
### registerShortcut() API
- `pi.registerShortcut(keyId, { description, handler })` — registers a hotkey for the extension
- Handler signature: `async (ctx: ExtensionContext) => void`
- Always guard with `if (!ctx.hasUI) return;` at the top of the handler
- Shortcuts are checked FIRST in input dispatch (before built-in keybindings)
- If a shortcut conflicts with a reserved built-in, it is **silently skipped** — no error shown unless `--verbose`
### Key ID Format
Format: `[modifier+[modifier+]]key` (lowercase, order of modifiers doesn't matter)
**Modifiers:** `ctrl`, `shift`, `alt`
**Base keys:**
- Letters: `a` through `z`
- Special: `escape`/`esc`, `enter`/`return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right`
- Function: `f1` through `f12`
- Symbols: `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`
**Modifier combos:** `ctrl+x`, `shift+x`, `alt+x`, `ctrl+shift+x`, `ctrl+alt+x`, `shift+alt+x`, `ctrl+shift+alt+x`
### Reserved Keys (CANNOT be overridden by extensions)
These are in `RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS` and will be silently skipped:
| Key | Action |
| -------------- | ---------------------- |
| `escape` | interrupt |
| `ctrl+c` | clear / copy |
| `ctrl+d` | exit |
| `ctrl+z` | suspend |
| `shift+tab` | cycleThinkingLevel |
| `ctrl+p` | cycleModelForward |
| `ctrl+shift+p` | cycleModelBackward |
| `ctrl+l` | selectModel |
| `ctrl+o` | expandTools |
| `ctrl+t` | toggleThinking |
| `ctrl+g` | externalEditor |
| `alt+enter` | followUp |
| `enter` | submit / selectConfirm |
| `ctrl+k` | deleteToLineEnd |
### Non-Reserved Built-in Keys (CAN be overridden, Pi warns)
| Key | Action |
| ----------------------------------------------------------------------------- | ------------------------ |
| `ctrl+a` | cursorLineStart |
| `ctrl+b` | cursorLeft |
| `ctrl+e` | cursorLineEnd |
| `ctrl+f` | cursorRight |
| `ctrl+n` | toggleSessionNamedFilter |
| `ctrl+r` | renameSession |
| `ctrl+s` | toggleSessionSort |
| `ctrl+u` | deleteToLineStart |
| `ctrl+v` | pasteImage |
| `ctrl+w` | deleteWordBackward |
| `ctrl+y` | yank |
| `ctrl+]` | jumpForward |
| `ctrl+-` | undo |
| `ctrl+alt+]` | jumpBackward |
| `alt+b`, `alt+d`, `alt+f`, `alt+y` | cursor/word operations |
| `alt+up` | dequeue |
| `shift+enter` | newLine |
| Arrow keys, `home`, `end`, `pageUp`, `pageDown`, `backspace`, `delete`, `tab` | navigation/editing |
### Safe Keys for Extensions (FREE, no conflicts)
**ctrl+letter (universally safe):**
- `ctrl+x` — confirmed working
- `ctrl+q` — may be intercepted by terminal XON/XOFF flow control
- `ctrl+h` — alias for backspace in some terminals, use with caution
**Function keys:** `f1` through `f12` — all unbound, universally compatible
### macOS Terminal Compatibility
This is CRITICAL for building extensions that work on macOS:
| Combo | Legacy Terminal (Terminal.app, iTerm2) | Kitty Protocol (Kitty, Ghostty, WezTerm) |
| ------------------- | ---------------------------------------------------- | ---------------------------------------- |
| `ctrl+letter` | YES | YES |
| `alt+letter` | NO — types special characters (ø, ∫, etc.) | YES |
| `ctrl+alt+letter` | SOMETIMES — may conflict with macOS system shortcuts | YES |
| `ctrl+shift+letter` | NO — needs Kitty protocol | YES |
| `shift+alt+letter` | NO — needs Kitty protocol | YES |
| Function keys | YES | YES |
**Rule of thumb on macOS:** Use `ctrl+letter` (from the free list) or `f1``f12` for guaranteed compatibility. Avoid `alt+`, `ctrl+shift+`, and `ctrl+alt+` unless targeting Kitty-protocol terminals only.
### Keybindings Customization (keybindings.json)
- Location: `~/.pi/agent/keybindings.json`
- Users can remap ANY action (including reserved ones) to different keys
- Format: `{ "actionName": ["key1", "key2"] }`
- When a reserved action is remapped away from a key, that key becomes available for extensions
- The conflict check uses EFFECTIVE keybindings (after user remaps), not defaults
### Key Helper (from @mariozechner/pi-tui)
- `Key.ctrl("x")``"ctrl+x"`
- `Key.shift("tab")``"shift+tab"`
- `Key.alt("left")``"alt+left"`
- `Key.ctrlShift("p")``"ctrl+shift+p"`
- `Key.ctrlAlt("p")``"ctrl+alt+p"`
- `matchesKey(data, keyId)` — test if input data matches a key ID
### Debugging Shortcuts
- Run with `pi --verbose` to see `[Extension issues]` section at startup
- Shortcut conflicts show as warnings: "Extension shortcut 'X' conflicts with built-in shortcut. Skipping."
- Extension shortcut errors appear as red text in the chat area
- Shortcuts not matching in `matchesKey()` means the terminal isn't sending the expected escape sequence
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi keybindings documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -f markdown -o /tmp/pi-keybindings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -o /tmp/pi-keybindings-docs.md
```
Then read /tmp/pi-keybindings-docs.md to have the freshest reference.
Search the local codebase for existing extensions that use registerShortcut() to find working patterns.
## How to Respond
- ALWAYS check if the requested key combo is reserved before recommending it
- ALWAYS warn about macOS compatibility issues with alt/shift combos
- Provide COMPLETE registerShortcut() code with proper guard clauses
- Include the Key helper import if using Key.ctrl() style
- Recommend safe alternatives when a requested key is taken
- Show how to debug with `--verbose` if shortcuts aren't firing
- When suggesting keys, prefer this priority: free ctrl+letter > function keys > overridable non-reserved keys
+57
View File
@@ -0,0 +1,57 @@
---
name: pi-orchestrator
description: Primary meta-agent that coordinates experts and builds Pi components
tools: read,write,edit,bash,grep,find,ls,query_experts
---
You are **Pi Pi** — a meta-agent that builds Pi agents. You create extensions, themes, skills, settings, prompt templates, and TUI components for the Pi coding agent.
## Your Team
You have a team of {{EXPERT_COUNT}} domain experts who research Pi documentation in parallel:
{{EXPERT_NAMES}}
## How You Work
### Phase 1: Research (PARALLEL)
When given a build request:
1. Identify which domains are relevant
2. Call `query_experts` ONCE with an array of ALL relevant expert queries — they run as concurrent subprocesses in PARALLEL
3. Ask specific questions: "How do I register a custom tool with renderCall?" not "Tell me about extensions"
4. Wait for the combined response before proceeding
### Phase 2: Build
Once you have research from all experts:
1. Synthesize the findings into a coherent implementation plan
2. WRITE the actual files using your code tools (read, write, edit, bash, grep, find, ls)
3. Create complete, working implementations — no stubs or TODOs
4. Follow existing patterns found in the codebase
## Expert Catalog
{{EXPERT_CATALOG}}
## Rules
1. **ALWAYS query experts FIRST** before writing any Pi-specific code. You need fresh documentation.
2. **Query experts IN PARALLEL** — call query_experts once with all relevant queries in the array.
3. **Be specific** in your questions — mention the exact feature, API method, or component you need.
4. **You write the code** — experts only research. They cannot modify files.
5. **Follow Pi conventions** — use TypeBox for schemas, StringEnum for Google compat, proper imports.
6. **Create complete files** — every extension must have proper imports, type annotations, and all features.
7. **Include a justfile entry** if creating a new extension (format: `pi -e extensions/<name>.ts`).
## What You Can Build
- **Extensions** (.ts files) — custom tools, event hooks, commands, UI components
- **Themes** (.json files) — color schemes with all 51 tokens
- **Skills** (SKILL.md directories) — capability packages with scripts
- **Settings** (settings.json) — configuration files
- **Prompt Templates** (.md files) — reusable prompts with arguments
- **Agent Definitions** (.md files) — agent personas with frontmatter
## File Locations
- Extensions: `extensions/` or `.pi/extensions/`
- Themes: `.pi/themes/`
- Skills: `.pi/skills/`
- Settings: `.pi/settings.json`
- Prompts: `.pi/prompts/`
- Agents: `.pi/agents/`
- Teams: `.pi/agents/teams.yaml`
+70
View File
@@ -0,0 +1,70 @@
---
name: prompt-expert
description: Pi prompt templates expert — knows the single-file .md format, frontmatter, positional arguments ($1, $@, ${@:N}), discovery locations, and /template invocation
tools: read,grep,find,ls,bash
---
You are a prompt templates expert for the Pi coding agent. You know EVERYTHING about creating Pi prompt templates.
## Your Expertise
- Prompt templates are single Markdown files that expand into full prompts
- Filename becomes the command: `review.md``/review`
- Simple, lightweight — one file per template, no directories or scripts needed
### Format
```markdown
---
description: What this template does
---
Your prompt content here with $1 and $@ arguments
```
### Arguments
- `$1`, `$2`, ... — positional arguments
- `$@` or `$ARGUMENTS` — all arguments joined
- `${@:N}` — args from Nth position (1-indexed)
- `${@:N:L}` — L args starting at position N
### Locations
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Packages: `prompts/` directories or `pi.prompts` entries in package.json
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)
### Discovery
- Non-recursive — only direct .md files in prompts/ root
- For subdirectories, add explicitly via settings or package manifest
### Key Differences from Skills
- Single file (no directory structure needed)
- No scripts, no setup, no references
- Just markdown with optional argument substitution
- Lightweight reusable prompts, not capability packages
### Usage
```
/review # Expands review.md
/component Button # Expands with argument
/component Button "click handler" # Multiple arguments
```
### Description
- Optional frontmatter field
- If missing, first non-empty line is used as description
- Shown in autocomplete when typing `/`
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi prompt templates documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -f markdown -o /tmp/pi-prompt-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -o /tmp/pi-prompt-docs.md
```
Then read /tmp/pi-prompt-docs.md to have the freshest reference. Also search the local codebase (.pi/prompts/) for existing prompt template examples.
## How to Respond
- Provide COMPLETE .md files with proper frontmatter
- Include argument placeholders where appropriate
- Write specific, actionable descriptions
- Keep templates focused — one purpose per file
- Show the filename and the /command it creates
+42
View File
@@ -0,0 +1,42 @@
---
name: skill-expert
description: Pi skills expert — knows SKILL.md format, frontmatter fields, directory structure, validation rules, and skill command registration
tools: read,grep,find,ls,bash
---
You are a skills expert for the Pi coding agent. You know EVERYTHING about creating Pi skills.
## Your Expertise
- Skills are self-contained capability packages loaded on-demand
- SKILL.md format with YAML frontmatter + markdown body
- Frontmatter fields:
- name (required): max 64 chars, lowercase a-z, 0-9, hyphens, must match parent directory
- description (required): max 1024 chars, determines when agent loads the skill
- license (optional)
- compatibility (optional): max 500 chars
- metadata (optional): arbitrary key-value
- allowed-tools (optional): space-delimited pre-approved tools
- disable-model-invocation (optional): hide from system prompt, require /skill:name
- Directory structure: my-skill/SKILL.md + scripts/ + references/ + assets/
- Skill locations: ~/.pi/agent/skills/, .pi/skills/, packages, settings.json
- Discovery: direct .md files in root, recursive SKILL.md under subdirs
- Skill commands: /skill:name with arguments
- Validation: name matching, character limits, missing description = not loaded
- Agent Skills standard (agentskills.io)
- Using skills from other harnesses (Claude Code, Codex)
- Progressive disclosure: only descriptions in system prompt, full content loaded on-demand
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi skills documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -f markdown -o /tmp/pi-skill-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -o /tmp/pi-skill-docs.md
```
Then read /tmp/pi-skill-docs.md to have the freshest reference. Also search the local codebase for existing skill examples.
## How to Respond
- Provide COMPLETE SKILL.md with valid frontmatter
- Include setup scripts if dependencies are needed
- Show proper directory structure
- Write specific, trigger-worthy descriptions
- Include helper scripts and reference docs as needed
+40
View File
@@ -0,0 +1,40 @@
---
name: theme-expert
description: Pi themes expert — knows the JSON format, all 51 color tokens, vars system, hex/256-color values, hot reload, and theme distribution
tools: read,grep,find,ls,bash
---
You are a themes expert for the Pi coding agent. You know EVERYTHING about creating and distributing Pi themes.
## Your Expertise
- Theme JSON format with $schema, name, vars, colors sections
- All 51 required color tokens across 7 categories:
- Core UI (11): accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText
- Backgrounds & Content (11): selectedBg, userMessageBg, userMessageText, customMessageBg, customMessageText, customMessageLabel, toolPendingBg, toolSuccessBg, toolErrorBg, toolTitle, toolOutput
- Markdown (10): mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
- Tool Diffs (3): toolDiffAdded, toolDiffRemoved, toolDiffContext
- Syntax Highlighting (9): syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
- Thinking Borders (6): thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh
- Bash Mode (1): bashMode
- Optional HTML export section (pageBg, cardBg, infoBg)
- Color value formats: hex (#ff0000), 256-color index (0-255), variable reference, empty string for default
- vars system for reusable color definitions
- Theme locations: ~/.pi/agent/themes/, .pi/themes/
- Hot reload when editing active custom theme
- Selection via /settings or settings.json
- $schema URL for editor validation
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi themes documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -f markdown -o /tmp/pi-theme-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -o /tmp/pi-theme-docs.md
```
Then read /tmp/pi-theme-docs.md to have the freshest reference. Also search the local codebase (.pi/themes/) for existing theme examples.
## How to Respond
- Provide COMPLETE theme JSON with ALL 51 color tokens (no partial themes)
- Use vars for palette consistency
- Include the $schema for validation
- Suggest color harmonies based on the user's aesthetic preference
- Mention hot reload and testing tips
+85
View File
@@ -0,0 +1,85 @@
---
name: tui-expert
description: Pi TUI expert — knows all built-in components (Text, Box, Container, Markdown, Image, SelectList, SettingsList, BorderedLoader), custom components, overlays, keyboard input, widgets, footers, and custom editors
tools: read,grep,find,ls,bash
---
You are a TUI (Terminal User Interface) expert for the Pi coding agent. You know EVERYTHING about building custom UI components and rendering.
## Your Expertise
### Component Interface
- render(width: number): string[] — lines must not exceed width
- handleInput?(data: string) — keyboard input when focused
- wantsKeyRelease? — for Kitty protocol key release events
- invalidate() — clear cached render state
### Built-in Components (from @mariozechner/pi-tui)
- Text: multi-line text with word wrapping, paddingX, paddingY, background function
- Box: container with padding and background color
- Container: groups children vertically, addChild/removeChild
- Spacer: empty vertical space
- Markdown: renders markdown with syntax highlighting
- Image: renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm)
- SelectList: selection dialog with theme, onSelect/onCancel
- SettingsList: toggle settings with theme
### From @mariozechner/pi-coding-agent
- DynamicBorder: border with color function — ALWAYS type the param: (s: string) => theme.fg("accent", s)
- BorderedLoader: spinner with abort support
- CustomEditor: base class for custom editors (vim mode, etc.)
### Keyboard Input
- matchesKey(data, Key.up/down/enter/escape/etc.)
- Key modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
- String format: "enter", "ctrl+c", "shift+tab"
### Width Utilities
- visibleWidth(str) — display width ignoring ANSI codes
- truncateToWidth(str, width, ellipsis?) — truncate with ellipsis
- wrapTextWithAnsi(str, width) — word wrap preserving ANSI codes
### UI Patterns (copy-paste ready)
1. Selection Dialog: SelectList + DynamicBorder + ctx.ui.custom()
2. Async with Cancel: BorderedLoader with signal
3. Settings/Toggles: SettingsList + getSettingsListTheme()
4. Status Indicator: ctx.ui.setStatus(key, styledText)
5. Widgets: ctx.ui.setWidget(key, lines | factory, { placement })
6. Custom Footer: ctx.ui.setFooter(factory)
7. Custom Editor: extend CustomEditor, ctx.ui.setEditorComponent(factory)
8. Overlays: ctx.ui.custom(component, { overlay: true, overlayOptions })
### Focusable Interface (IME Support)
- CURSOR_MARKER for hardware cursor positioning
- Container propagation for embedded inputs
### Theming in Components
- theme.fg(color, text) for foreground
- theme.bg(color, text) for background
- theme.bold(text) for bold
- Invalidation pattern: rebuild themed content in invalidate()
- getMarkdownTheme() for Markdown components
### Key Rules
1. Always use theme from callback — not imported directly
2. Always type DynamicBorder color param: (s: string) =>
3. Call tui.requestRender() after state changes in handleInput
4. Return { render, invalidate, handleInput } for custom components
5. Use Text with padding (0, 0) — Box handles padding
6. Cache rendered output with cachedWidth/cachedLines pattern
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi TUI documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -f markdown -o /tmp/pi-tui-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -o /tmp/pi-tui-docs.md
```
Then read /tmp/pi-tui-docs.md to have the freshest reference. Also search the local codebase for existing TUI component examples in extensions/.
## How to Respond
- Provide COMPLETE, WORKING component code
- Include all imports from @mariozechner/pi-tui and @mariozechner/pi-coding-agent
- Show the ctx.ui.custom() wrapper for interactive components
- Handle invalidation properly for theme changes
- Include keyboard input handling where relevant
- Show both the component class and the registration/usage code
+22
View File
@@ -0,0 +1,22 @@
---
name: plan-reviewer
description: Plan critic — reviews, challenges, and validates implementation plans
tools: read,grep,find,ls
---
You are a plan reviewer agent. Your job is to critically evaluate implementation plans.
For each plan you review:
- Challenge assumptions — are they grounded in the actual codebase?
- Identify missing steps, edge cases, or dependencies the planner overlooked
- Flag risks: breaking changes, migration concerns, performance pitfalls
- Check feasibility — can each step actually be done with the tools and patterns available?
- Evaluate ordering — are steps in the right sequence? Are there hidden dependencies?
- Call out scope creep or over-engineering
Output a structured critique with:
1. **Strengths** — what the plan gets right
2. **Issues** — concrete problems ranked by severity
3. **Missing** — steps or considerations the plan omitted
4. **Recommendations** — specific, actionable changes to improve the plan
Be direct and specific. Reference actual files and patterns from the codebase when possible. Do NOT modify files.
+6
View File
@@ -0,0 +1,6 @@
---
name: planner
description: Architecture and implementation planning
tools: read,grep,find,ls
---
You are a planner agent. Analyze requirements and produce clear, actionable implementation plans. Identify files to change, dependencies, and risks. Output a numbered step-by-step plan. Do NOT modify files.
+6
View File
@@ -0,0 +1,6 @@
---
name: red-team
description: Security and adversarial testing
tools: read,bash,grep,find,ls
---
You are a red team agent. Find security vulnerabilities, edge cases, and failure modes. Check for injection risks, exposed secrets, missing validation, and unsafe defaults. Report findings with severity ratings. Do NOT modify files.
+6
View File
@@ -0,0 +1,6 @@
---
name: reviewer
description: Code review and quality checks
tools: read,bash,grep,find,ls
---
You are a code reviewer agent. Review code for bugs, security issues, style problems, and improvements. Run tests if available. Be concise and use bullet points. Do NOT modify files.
+6
View File
@@ -0,0 +1,6 @@
---
name: scout
description: Fast recon and codebase exploration
tools: read,grep,find,ls
---
You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.
+31
View File
@@ -0,0 +1,31 @@
full:
- scout
- planner
- builder
- reviewer
- documenter
- red-team
plan-build:
- planner
- builder
- reviewer
info:
- scout
- documenter
- reviewer
frontend:
- planner
- builder
- bowser
pi-pi:
- ext-expert
- theme-expert
- skill-expert
- config-expert
- tui-expert
- prompt-expert
- agent-expert
+279
View File
@@ -0,0 +1,279 @@
bashToolPatterns:
- pattern: '\brm\s+(-[^\s]*)*-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+--recursive'
reason: rm with --recursive flag
- pattern: '\brm\s+--force'
reason: rm with --force flag
- pattern: '\bsudo\s+rm\b'
reason: sudo rm
- pattern: '\brmdir\s+--ignore-fail-on-non-empty'
reason: rmdir ignore-fail
- pattern: '\bchmod\s+(-[^\s]+\s+)*777\b'
reason: chmod 777 (world writable)
- pattern: '\bchmod\s+-[Rr].*777'
reason: recursive chmod 777
- pattern: '\bchown\s+-[Rr].*\broot\b'
reason: recursive chown to root
- pattern: '\bgit\s+reset\s+--hard\b'
reason: git reset --hard (use --soft or stash)
- pattern: '\bgit\s+clean\s+(-[^\s]*)*-[fd]'
reason: git clean with force/directory flags
- pattern: '\bgit\s+push\s+.*--force(?!-with-lease)'
reason: git push --force (use --force-with-lease)
- pattern: '\bgit\s+push\s+(-[^\s]*)*-f\b'
reason: git push -f (use --force-with-lease)
- pattern: '\bgit\s+stash\s+clear\b'
reason: git stash clear (deletes ALL stashes)
- pattern: '\bgit\s+reflog\s+expire\b'
reason: git reflog expire (destroys recovery mechanism)
- pattern: '\bgit\s+gc\s+.*--prune=now'
reason: git gc --prune=now (can lose dangling commits)
- pattern: '\bgit\s+filter-branch\b'
reason: git filter-branch (rewrites entire history)
- pattern: '\bgit\s+checkout\s+--\s*\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+restore\s+\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+stash\s+drop\b'
reason: Permanently deletes a stash
ask: true
- pattern: '\bgit\s+branch\s+(-[^\s]*)*-D'
reason: Force deletes branch (even if unmerged)
ask: true
- pattern: '\bgit\s+push\s+\S+\s+--delete\b'
reason: Deletes remote branch
ask: true
- pattern: '\bgit\s+push\s+\S+\s+:\S+'
reason: Deletes remote branch (old syntax)
ask: true
- pattern: '\bmkfs\.'
reason: filesystem format command
- pattern: '\bdd\s+.*of=/dev/'
reason: dd writing to device
- pattern: '\bkill\s+-9\s+-1\b'
reason: kill all processes
- pattern: '\bkillall\s+-9\b'
reason: killall -9
- pattern: '\bpkill\s+-9\b'
reason: pkill -9
- pattern: '\bhistory\s+-c\b'
reason: clearing shell history
- pattern: '\baws\s+s3\s+rm\s+.*--recursive'
reason: aws s3 rm --recursive (deletes all objects)
- pattern: '\baws\s+s3\s+rb\s+.*--force'
reason: aws s3 rb --force (force removes bucket)
- pattern: '\baws\s+ec2\s+terminate-instances\b'
reason: aws ec2 terminate-instances
- pattern: '\baws\s+rds\s+delete-db-instance\b'
reason: aws rds delete-db-instance
- pattern: '\baws\s+cloudformation\s+delete-stack\b'
reason: aws cloudformation delete-stack (deletes infrastructure)
- pattern: '\baws\s+dynamodb\s+delete-table\b'
reason: aws dynamodb delete-table
- pattern: '\baws\s+eks\s+delete-cluster\b'
reason: aws eks delete-cluster
- pattern: '\baws\s+lambda\s+delete-function\b'
reason: aws lambda delete-function
- pattern: '\baws\s+iam\s+delete-role\b'
reason: aws iam delete-role
- pattern: '\baws\s+iam\s+delete-user\b'
reason: aws iam delete-user
- pattern: '\bgcloud\s+projects\s+delete\b'
reason: gcloud projects delete (DELETES ENTIRE PROJECT)
- pattern: '\bgcloud\s+compute\s+instances\s+delete\b'
reason: gcloud compute instances delete
- pattern: '\bgcloud\s+sql\s+instances\s+delete\b'
reason: gcloud sql instances delete
- pattern: '\bgcloud\s+container\s+clusters\s+delete\b'
reason: gcloud container clusters delete (GKE)
- pattern: '\bgcloud\s+storage\s+rm\s+.*-r'
reason: gcloud storage rm -r (recursive delete)
- pattern: '\bgcloud\s+functions\s+delete\b'
reason: gcloud functions delete
- pattern: '\bgcloud\s+iam\s+service-accounts\s+delete\b'
reason: gcloud iam service-accounts delete
- pattern: '\bgcloud\s+run\s+services\s+delete\b'
reason: gcloud run services delete (deletes Cloud Run service)
- pattern: '\bgcloud\s+run\s+jobs\s+delete\b'
reason: gcloud run jobs delete (deletes Cloud Run job)
- pattern: '\bgcloud\s+services\s+disable\b'
reason: gcloud services disable (disables GCP APIs)
- pattern: '\bgcloud\s+iam\s+roles\s+delete\b'
reason: gcloud iam roles delete (deletes IAM role)
- pattern: '\bgcloud\s+iam\s+policies\b'
reason: gcloud iam policies (modifies IAM policies)
ask: true
- pattern: '\bfirebase\s+projects:delete\b'
reason: firebase projects:delete (deletes entire project)
- pattern: '\bfirebase\s+firestore:delete\s+.*--all-collections'
reason: firebase firestore:delete --all-collections (wipes all data)
- pattern: '\bfirebase\s+database:remove\b'
reason: firebase database:remove (wipes Realtime DB)
- pattern: '\bfirebase\s+hosting:disable\b'
reason: firebase hosting:disable
- pattern: '\bfirebase\s+functions:delete\b'
reason: firebase functions:delete
- pattern: '\bvercel\s+remove\s+.*--yes'
reason: vercel remove --yes (removes deployment)
- pattern: '\bvercel\s+projects\s+rm\b'
reason: vercel projects rm (deletes project)
- pattern: '\bvercel\s+env\s+rm\b'
reason: vercel env rm (removes env variables)
- pattern: '\bvercel\s+rm\b'
reason: vercel rm (removes deployment)
- pattern: '\bvercel\s+remove\b'
reason: vercel remove (removes deployment)
- pattern: '\bvercel\s+domains\s+rm\b'
reason: vercel domains rm (removes custom domain)
- pattern: '\bnetlify\s+sites:delete\b'
reason: netlify sites:delete (deletes entire site)
- pattern: '\bnetlify\s+functions:delete\b'
reason: netlify functions:delete
- pattern: '\bwrangler\s+delete\b'
reason: wrangler delete (deletes Worker)
- pattern: '\bwrangler\s+r2\s+bucket\s+delete\b'
reason: wrangler r2 bucket delete
- pattern: '\bwrangler\s+kv:namespace\s+delete\b'
reason: wrangler kv:namespace delete
- pattern: '\bwrangler\s+d1\s+delete\b'
reason: wrangler d1 delete (deletes database)
- pattern: '\bwrangler\s+queues\s+delete\b'
reason: wrangler queues delete
- pattern: 'DELETE\s+FROM\s+\w+\s*;'
reason: DELETE without WHERE clause (will delete ALL rows)
- pattern: 'DELETE\s+\*\s+FROM'
reason: DELETE * (will delete ALL rows)
- pattern: '\bTRUNCATE\s+TABLE\b'
reason: TRUNCATE TABLE (will delete ALL rows)
- pattern: '\bDROP\s+TABLE\b'
reason: DROP TABLE
- pattern: '\bDROP\s+DATABASE\b'
reason: DROP DATABASE
- pattern: '\bDROP\s+SCHEMA\b'
reason: DROP SCHEMA
- pattern: '\bDELETE\s+FROM\s+\w+\s+WHERE\b.*\bid\s*='
reason: SQL DELETE with specific ID
ask: true
zeroAccessPaths:
- ".env"
- ".env.local"
- ".env.development"
- ".env.production"
- ".env.staging"
- ".env.test"
- ".env.*.local"
- "*.env"
- "~/.ssh/"
- "~/.gnupg/"
- "~/.aws/"
- "~/.config/gcloud/"
- "*-credentials.json"
- "*serviceAccount*.json"
- "*service-account*.json"
- "~/.azure/"
- "~/.kube/"
- "kubeconfig"
- "*-secret.yaml"
- "secrets.yaml"
- "~/.docker/"
- "*.pem"
- "*.key"
- "*.p12"
- "*.pfx"
- "*.tfstate"
- "*.tfstate.backup"
- ".terraform/"
- ".vercel/"
- ".netlify/"
- "firebase-adminsdk*.json"
- "serviceAccountKey.json"
- ".supabase/"
- "~/.netrc"
- "~/.npmrc"
- "~/.pypirc"
- "~/.git-credentials"
- ".git-credentials"
- "dump.sql"
- "backup.sql"
- "*.dump"
readOnlyPaths:
- /etc/
- /usr/
- /bin/
- /sbin/
- /boot/
- /root/
- ~/.bash_history
- ~/.zsh_history
- ~/.node_repl_history
- ~/.bashrc
- ~/.zshrc
- ~/.profile
- ~/.bash_profile
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "Gemfile.lock"
- "poetry.lock"
- "Pipfile.lock"
- "composer.lock"
- "Cargo.lock"
- "go.sum"
- "flake.lock"
- "bun.lockb"
- "uv.lock"
- "npm-shrinkwrap.json"
- "*.lock"
- "*.lockb"
- "*.min.js"
- "*.min.css"
- "*.bundle.js"
- "*.chunk.js"
- dist/
- build/
- .next/
- .nuxt/
- .output/
- node_modules/
- __pycache__/
- .venv/
- venv/
- target/
noDeletePaths:
- ~/.claude/
- CLAUDE.md
- "LICENSE"
- "LICENSE.*"
- "COPYING"
- "COPYING.*"
- "NOTICE"
- "PATENTS"
- "README.md"
- "README.*"
- "CONTRIBUTING.md"
- "CHANGELOG.md"
- "CODE_OF_CONDUCT.md"
- "SECURITY.md"
- .git/
- .gitignore
- .gitattributes
- .gitmodules
- .github/
- .gitlab-ci.yml
- .circleci/
- Jenkinsfile
- .travis.yml
- azure-pipelines.yml
- Dockerfile
- "Dockerfile.*"
- docker-compose.yml
- "docker-compose.*.yml"
- .dockerignore
+62
View File
@@ -0,0 +1,62 @@
# Infrastructure Access
# All values live in `.env` (gitignored). This file maps the topology.
## Server
| Var | Purpose |
|-----|---------|
| `SSH_USER`, `SSH_HOST`, `SSH_PORT` | Primary server SSH access |
## Incus Containers (on primary server)
| Container | Internal IP | Status | Purpose |
|-----------------|-----------------|---------|---------------|
| cr-server-new | 10.213.16.224 | RUNNING | CharityRight |
| qc-server-new | 10.213.16.234 | RUNNING | QuikCue |
| qc-server | — | STOPPED | legacy |
## HAProxy (on primary server)
| Domain pattern | Backend |
|----------------------|----------------------|
| charityright domains | → cr-server-new:443/80 |
| quikcue domains | → qc-server-new:443/80 |
| antivirus.quikcue.com| → localhost:8877 |
| SSH (gitea) | → qc-server-new:2224 |
## Databases
| Var | Type | Purpose |
|-----|------|---------|
| `DATABASE_URL` | Postgres | donation_warehouse (port 5000 on primary) |
| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` | MySQL | CharityRight legacy (DigitalOcean managed) |
| `REDIS_HOST`, `REDIS_PASSWORD`, `REDIS_PORT` | Redis | CharityRight sessions/cache |
## Services on Server
| Path | Service | Key Vars |
|------|---------|----------|
| `/opt/ayn-antivirus` | AYN Antivirus scanner + dashboard | `ANTHROPIC_API_KEY` |
| `/opt/enthuse-db-sync-v2` | Enthuse donation sync | `ENTHUSE_EMAIL`, `TOTP_SECRET`, `GOOGLE_CLIENT_*` |
| `/opt/launchgood-sync` | LaunchGood donation sync | `LG_EMAIL`, `LG_PASSWORD` |
| `/root/legacy-donation-system-laravel` | CharityRight Laravel app | `STRIPE_*`, `PAYPAL_*`, `GOCARDLESS_*`, `POSTMARK_TOKEN` |
| `/root/redis-v2` | Redis instance | `REDIS_PASSWORD` |
## Payment Providers
| Var prefix | Provider |
|------------|----------|
| `STRIPE_*` | Stripe (live) |
| `PAYPAL_*` | PayPal (live) |
| `GOCARDLESS_*` | GoCardless (live) |
## Mail
| Var | Provider |
|-----|----------|
| `SENDGRID_TX_API_KEY` | SendGrid |
| `POSTMARK_TOKEN` | Postmark (active mailer) |
## Third-party Integrations
| Var | Service |
|-----|---------|
| `N3O_*_ENDPOINT` | N3O/Engage donation import hooks |
| `ZAPIER_WEBHOOK_ENDPOINT` | Zapier automation |
| `GOOGLE_PLACES_API_KEY` | Google Places autocomplete |
| `CT_STRAVA_*` | Strava challenge tracker |
| `WORDPRESS_URL`, `WORDPRESS_KEY` | WordPress (Cloudways) |
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME` | Telegram bot (@cr_management_smart_bot) |
| `TELEGRAM_ALLOWED_USERS` | Comma-separated Telegram user IDs (empty = open) |
+3
View File
@@ -0,0 +1,3 @@
events.jsonl
summary.json
report.md
View File
+6
View File
@@ -0,0 +1,6 @@
{
"theme": "synthwave",
"prompts": [
"../.claude/commands"
]
}
+120
View File
@@ -0,0 +1,120 @@
---
name: bowser
description: Headless browser automation using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, web scraping, or browser automation that can run in the background. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel.
allowed-tools: Bash
---
# Playwright Bowser
## Purpose
Automate browsers using `playwright-cli` (via `@playwright/cli`) — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context.
## Prerequisites
Ensure the package is installed in the project:
```bash
bun add -d @playwright/cli
bunx playwright install chromium
```
## Key Details
- **Headless by default** — pass `--headed` to `open` to see the browser
- **Parallel sessions** — use `-s=<name>` to run multiple independent browser instances
- **Persistent profiles** — cookies and storage state preserved between calls
- **Token-efficient** — CLI-based, no accessibility trees or tool schemas in context
- **Vision mode** (opt-in) — set `PLAYWRIGHT_MCP_CAPS=vision` to receive screenshots as image responses in context instead of just saving to disk
## Sessions
**Always use a named session.** Derive a short, descriptive kebab-case name from the user's prompt. This gives each task a persistent browser profile (cookies, localStorage, history) that accumulates across calls.
```bash
# Derive session name from prompt context:
# "test the checkout flow on mystore.com" → -s=mystore-checkout
# "scrape pricing from competitor.com" → -s=competitor-pricing
# "UI test the login page" → -s=login-ui-test
bunx playwright-cli -s=mystore-checkout open https://mystore.com --persistent
bunx playwright-cli -s=mystore-checkout snapshot
bunx playwright-cli -s=mystore-checkout click e12
```
Managing sessions:
```bash
bunx playwright-cli list # list all sessions
bunx playwright-cli close-all # close all sessions
bunx playwright-cli -s=<name> close # close specific session
bunx playwright-cli -s=<name> delete-data # wipe session profile
```
## Quick Reference
```
Core: open [url], goto <url>, click <ref>, fill <ref> <text>, type <text>, snapshot, screenshot [ref], close
Navigate: go-back, go-forward, reload
Keyboard: press <key>, keydown <key>, keyup <key>
Mouse: mousemove <x> <y>, mousedown, mouseup, mousewheel <dx> <dy>
Tabs: tab-list, tab-new [url], tab-close [index], tab-select <index>
Save: screenshot [ref], pdf, screenshot --filename=f
Storage: state-save, state-load, cookie-*, localstorage-*, sessionstorage-*
Network: route <pattern>, route-list, unroute, network
DevTools: console, run-code <code>, tracing-start/stop, video-start/stop
Sessions: -s=<name> <cmd>, list, close-all, kill-all
Config: open --headed, open --browser=chrome, resize <w> <h>
```
## Workflow
1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch:
```bash
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent
# or headed:
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent --headed
# or with vision (screenshots returned as image responses in context):
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision bunx playwright-cli -s=<session-name> open <url> --persistent
```
3. Get element references via snapshot:
```bash
bunx playwright-cli snapshot
```
4. Interact using refs from snapshot:
```bash
bunx playwright-cli click <ref>
bunx playwright-cli fill <ref> "text"
bunx playwright-cli type "text"
bunx playwright-cli press Enter
```
5. Capture results:
```bash
bunx playwright-cli screenshot
bunx playwright-cli screenshot --filename=output.png
```
6. **Always close the session when done.** This is not optional — close the named session after finishing your task:
```bash
bunx playwright-cli -s=<session-name> close
```
## Configuration
If a `playwright-cli.json` exists in the working directory, use it automatically. If the user provides a path to a config file, use `--config path/to/config.json`. Otherwise, skip configuration — the env var and CLI defaults are sufficient.
```json
{
"browser": {
"browserName": "chromium",
"launchOptions": { "headless": true },
"contextOptions": { "viewport": { "width": 1440, "height": 900 } }
},
"outputDir": "./screenshots"
}
```
## Full Help
Run `bunx playwright-cli --help` or `bunx playwright-cli --help <command>` for detailed command usage.
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "catppuccin-mocha",
"vars": {
"bg": "#1e1e2e",
"bgDark": "#181825",
"bgDeep": "#13131e",
"surface": "#2a2a3c",
"selection": "#34344a",
"bgRed": "#2e1420",
"bgGreen": "#142218",
"bgPeach": "#2e2010",
"bgBlue": "#141e38",
"bgMauve": "#261840",
"bgTeal": "#122830",
"comment": "#d5bcff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#ff7eb3",
"maroon": "#ffa0b8",
"peach": "#ffb370",
"yellow": "#ffe585",
"green": "#7af5a0",
"teal": "#60f0d8",
"sky": "#6ae4ff",
"sapphire": "#5cceff",
"blue": "#7db8ff",
"lavender": "#bfb8ff",
"mauve": "#d9a0ff",
"flamingo": "#ffc4c4",
"pink": "#ffb0e0"
},
"colors": {
"accent": "mauve",
"border": "selection",
"borderAccent": "mauve",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgMauve",
"userMessageBg": "bgBlue",
"userMessageText": "fg",
"customMessageBg": "bgTeal",
"customMessageText": "fg",
"customMessageLabel": "teal",
"toolPendingBg": "bgPeach",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "peach",
"toolOutput": "fgSoft",
"mdHeading": "peach",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "sky",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "mauve",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "mauve",
"syntaxFunction": "blue",
"syntaxVariable": "pink",
"syntaxString": "green",
"syntaxNumber": "peach",
"syntaxType": "sky",
"syntaxOperator": "lavender",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "sky",
"thinkingHigh": "mauve",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "cyberpunk",
"vars": {
"bg": "#0a0a14",
"bgDark": "#06060e",
"bgDeep": "#040410",
"surface": "#12122a",
"selection": "#1a1a38",
"bgRed": "#2a0a12",
"bgOrange": "#2a1408",
"bgSky": "#081a30",
"bgCyan": "#0a2228",
"bgWarm": "#220a30",
"bgPink": "#2a0a22",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#ffe600",
"yellow": "#ffe600",
"cyan": "#00e5ff",
"magenta": "#ff00aa",
"red": "#ff1744",
"green": "#00e676",
"purple": "#aa00ff",
"blue": "#2979ff",
"orange": "#ff6d00"
},
"colors": {
"accent": "cyan",
"border": "magenta",
"borderAccent": "yellow",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "green",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "yellow",
"toolOutput": "fgSoft",
"mdHeading": "magenta",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "yellow",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "cyan",
"syntaxVariable": "yellow",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "blue",
"syntaxOperator": "magenta",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "magenta",
"bashMode": "orange"
}
}
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "dracula",
"vars": {
"bg": "#1a1b26",
"bgDark": "#161722",
"bgDeep": "#141520",
"surface": "#252738",
"selection": "#2c2e44",
"bgRed": "#2e1220",
"bgOrange": "#2e1c12",
"bgGreen": "#122e1a",
"bgCyan": "#122a2e",
"bgPurple": "#261536",
"bgPink": "#2e1228",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f8fcc4",
"cyan": "#8be9fd",
"green": "#50fa7b",
"orange": "#ffb86c",
"pink": "#ff79c6",
"purple": "#bd93f9",
"red": "#ff5555",
"yellow": "#f1fa8c",
"blue": "#6296e4"
},
"colors": {
"accent": "purple",
"border": "pink",
"borderAccent": "purple",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "cyan",
"selectedBg": "bgPurple",
"userMessageBg": "bgPink",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "pink",
"toolOutput": "fgSoft",
"mdHeading": "pink",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "pink",
"syntaxFunction": "green",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "cyan",
"syntaxOperator": "pink",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "everforest",
"vars": {
"bg": "#191f1d",
"bgDark": "#141a18",
"bg1": "#1e2522",
"bg2": "#222a28",
"surface": "#2c3532",
"selection": "#323e3a",
"bgRed": "#301718",
"bgOrange": "#302217",
"bgSky": "#192b34",
"bgCyan": "#172b26",
"bgWarm": "#351d29",
"bgPink": "#311c31",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#e7f4cd",
"red": "#eb7073",
"orange": "#f1a27e",
"yellow": "#eed096",
"green": "#bde481",
"aqua": "#78e292",
"teal": "#52e0bd",
"blue": "#78c8e2",
"purple": "#e689b5"
},
"colors": {
"accent": "green",
"border": "aqua",
"borderAccent": "green",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgCyan",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgSky",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "green",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "aqua",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "teal",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "green",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "green",
"syntaxVariable": "blue",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "aqua",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "green",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "gruvbox",
"vars": {
"bg": "#221f1c",
"bgDark": "#1c1a17",
"bgDeep": "#171412",
"surface": "#322d29",
"selection": "#3f3731",
"bgRed": "#341714",
"bgOrange": "#322215",
"bgSky": "#152432",
"bgCyan": "#142924",
"bgWarm": "#322b15",
"bgPink": "#321524",
"comment": "#fcebc5",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fb4b37",
"green": "#ebed5e",
"yellow": "#fcd783",
"blue": "#67a6e4",
"purple": "#ca74e7",
"aqua": "#81e4be",
"orange": "#fd953f"
},
"colors": {
"accent": "orange",
"border": "yellow",
"borderAccent": "orange",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "aqua",
"selectedBg": "bgWarm",
"userMessageBg": "bgOrange",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgSky",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "aqua",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "blue",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "orange",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "aqua",
"syntaxVariable": "blue",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "yellow",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "aqua",
"thinkingHigh": "yellow",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "midnight-ocean",
"vars": {
"deepBlue": "#0a192f",
"oceanBlue": "#0077be",
"teal": "#00ced1",
"cyan": "#4fd1ed",
"softWhite": "#e6f1ff",
"mutedBlue": "#233554",
"lightMutedBlue": "#a8b2d1",
"slate": "#8892b0",
"successGreen": "#64ffda",
"errorRed": "#ff5f56",
"warningAmber": "#ffd700",
"purple": "#c678dd"
},
"colors": {
"accent": "oceanBlue",
"border": "mutedBlue",
"borderAccent": "teal",
"borderMuted": 236,
"success": "successGreen",
"error": "errorRed",
"warning": "warningAmber",
"muted": "slate",
"dim": 240,
"text": "softWhite",
"thinkingText": "teal",
"selectedBg": "#112240",
"userMessageBg": "#112240",
"userMessageText": "softWhite",
"customMessageBg": "#112240",
"customMessageText": "softWhite",
"customMessageLabel": "teal",
"toolPendingBg": "deepBlue",
"toolSuccessBg": "#0d2521",
"toolErrorBg": "#331616",
"toolTitle": "cyan",
"toolOutput": "lightMutedBlue",
"mdHeading": "teal",
"mdLink": "oceanBlue",
"mdLinkUrl": "slate",
"mdCode": "cyan",
"mdCodeBlock": "#011627",
"mdCodeBlockBorder": "mutedBlue",
"mdQuote": "slate",
"mdQuoteBorder": "mutedBlue",
"mdHr": "mutedBlue",
"mdListBullet": "teal",
"toolDiffAdded": "successGreen",
"toolDiffRemoved": "errorRed",
"toolDiffContext": "slate",
"syntaxComment": "slate",
"syntaxKeyword": "purple",
"syntaxFunction": "teal",
"syntaxVariable": "cyan",
"syntaxString": "successGreen",
"syntaxNumber": "warningAmber",
"syntaxType": "oceanBlue",
"syntaxOperator": "teal",
"syntaxPunctuation": "lightMutedBlue",
"thinkingOff": "mutedBlue",
"thinkingMinimal": "oceanBlue",
"thinkingLow": "teal",
"thinkingMedium": "cyan",
"thinkingHigh": "warningAmber",
"thinkingXhigh": "errorRed",
"bashMode": "warningAmber"
},
"export": {
"pageBg": "#0a192f",
"cardBg": "#112240",
"infoBg": "#0077be"
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "nord",
"vars": {
"bg": "#1a1d23",
"bgDark": "#15181d",
"bgDeep": "#111316",
"surface": "#272b34",
"selection": "#2f3541",
"bgRed": "#2e1818",
"bgOrange": "#31241a",
"bgSky": "#1c2835",
"bgCyan": "#192c2d",
"bgWarm": "#291b30",
"bgPink": "#2d1927",
"comment": "#ccebf4",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"frost1": "#67e4e2",
"frost2": "#72cee8",
"frost3": "#67a5e4",
"frost4": "#5c97df",
"red": "#e85e6c",
"orange": "#ed7f5e",
"yellow": "#f5d189",
"green": "#92df6b",
"purple": "#e278e2",
"border": "#3e5974",
"dim": "#3d4c5b"
},
"colors": {
"accent": "frost2",
"border": "border",
"borderAccent": "frost2",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "frost1",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "frost2",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "frost2",
"mdLinkUrl": "comment",
"mdCode": "frost1",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "frost2",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "frost3",
"syntaxFunction": "frost2",
"syntaxVariable": "fg",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "frost1",
"syntaxOperator": "frost3",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "dim",
"thinkingLow": "frost4",
"thinkingMedium": "frost3",
"thinkingHigh": "frost2",
"thinkingXhigh": "frost1",
"bashMode": "yellow"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "ocean-breeze",
"vars": {
"bg": "#0d1b2a",
"bgDark": "#0a1520",
"bgDeep": "#081018",
"surface": "#152a3e",
"selection": "#1b3450",
"bgRed": "#2a1018",
"bgOrange": "#2a1e10",
"bgSky": "#0e2440",
"bgCyan": "#0c2a2e",
"bgWarm": "#2a1530",
"bgPink": "#2e1028",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#c2faf2",
"coral": "#ff6b6b",
"amber": "#ffd166",
"kelp": "#2eeab5",
"biolum": "#33fff7",
"foam": "#50b0e0",
"spray": "#7ec8e3",
"mist": "#a8d8ea",
"sand": "#ecf49a",
"purple": "#b48aef",
"pink": "#f772b9"
},
"colors": {
"accent": "biolum",
"border": "foam",
"borderAccent": "biolum",
"borderMuted": "surface",
"success": "kelp",
"error": "coral",
"warning": "amber",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "biolum",
"selectedBg": "selection",
"userMessageBg": "bgSky",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "spray",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "spray",
"toolOutput": "fgSoft",
"mdHeading": "mist",
"mdLink": "biolum",
"mdLinkUrl": "comment",
"mdCode": "kelp",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "spray",
"toolDiffAdded": "kelp",
"toolDiffRemoved": "coral",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "coral",
"syntaxFunction": "biolum",
"syntaxVariable": "spray",
"syntaxString": "kelp",
"syntaxNumber": "amber",
"syntaxType": "purple",
"syntaxOperator": "foam",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "foam",
"thinkingMedium": "spray",
"thinkingHigh": "biolum",
"thinkingXhigh": "pink",
"bashMode": "amber"
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "rose-pine",
"vars": {
"bg": "#1a1726",
"bgDark": "#161320",
"bgDeep": "#12101c",
"surface": "#242038",
"selection": "#2e2946",
"bgRed": "#2c1220",
"bgOrange": "#2a1c12",
"bgSky": "#122030",
"bgCyan": "#132a2e",
"bgWarm": "#2a1830",
"bgPink": "#301828",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f0a8be",
"love": "#f47a9e",
"gold": "#f8cc85",
"rose": "#f0c4c4",
"pine": "#50b8d8",
"foam": "#a8e0ea",
"iris": "#d4a8ff",
"orchid": "#e088d0",
"ember": "#f09060",
"green": "#78e0a0"
},
"colors": {
"accent": "iris",
"border": "orchid",
"borderAccent": "iris",
"borderMuted": "surface",
"success": "foam",
"error": "love",
"warning": "gold",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "foam",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "iris",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "gold",
"toolOutput": "fgSoft",
"mdHeading": "love",
"mdLink": "foam",
"mdLinkUrl": "comment",
"mdCode": "gold",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "rose",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "iris",
"toolDiffAdded": "green",
"toolDiffRemoved": "love",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "love",
"syntaxFunction": "foam",
"syntaxVariable": "fg",
"syntaxString": "gold",
"syntaxNumber": "iris",
"syntaxType": "pine",
"syntaxOperator": "orchid",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "pine",
"thinkingMedium": "iris",
"thinkingHigh": "foam",
"thinkingXhigh": "love",
"bashMode": "ember"
}
}
+82
View File
@@ -0,0 +1,82 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "synthwave",
"vars": {
"bg": "#262335",
"bgDark": "#241b2f",
"bgDeep": "#1e1d2d",
"surface": "#34294f",
"selection": "#463465",
"bgRed": "#3d1018",
"bgRedWarm": "#301510",
"bgOrange": "#2e1f10",
"bgSky": "#1a2e4a",
"bgCyan": "#152838",
"bgWarm": "#4a1e6a",
"bgPink": "#35153a",
"comment": "#fede5d",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fe4450",
"cyan": "#36f9f6",
"yellow": "#fede5d",
"pink": "#ff7edb",
"green": "#72f1b8",
"orange": "#ff8b39",
"purple": "#c792ea",
"blue": "#4d9de0"
},
"colors": {
"accent": "cyan",
"border": "pink",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "#4a9e6a",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "yellow",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "cyan",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "pink",
"syntaxType": "green",
"syntaxOperator": "cyan",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "tokyo-night",
"vars": {
"bg": "#1a1b26",
"bgDark": "#141520",
"bg1": "#1e2030",
"bg2": "#252840",
"surface": "#2a2d48",
"selection": "#353860",
"bgRed": "#301420",
"bgOrange": "#2e1e14",
"bgSky": "#162040",
"bgCyan": "#142530",
"bgWarm": "#301848",
"bgPink": "#2d1430",
"comment": "#90e8ff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"blue": "#7eaaff",
"cyan": "#72dfff",
"magenta": "#c9a5ff",
"purple": "#b48ef5",
"green": "#a8e06a",
"red": "#ff7a94",
"orange": "#ffa55c",
"yellow": "#f0c060",
"teal": "#20d4b0"
},
"colors": {
"accent": "blue",
"border": "purple",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "magenta",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "blue",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "blue",
"syntaxVariable": "purple",
"syntaxString": "green",
"syntaxNumber": "orange",
"syntaxType": "cyan",
"syntaxOperator": "teal",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "cyan",
"thinkingHigh": "magenta",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}
+23
View File
@@ -0,0 +1,23 @@
# Pi vs CC — Extension Playground
## Infrastructure Access
**Always read `.pi/infra.md` at the start of every session** — it contains live credentials and connection details.
Pi Coding Agent extension examples and experiments.
## Tooling
- **Package manager**: `bun` (not npm/yarn/pnpm)
- **Task runner**: `just` (see justfile)
- **Extensions run via**: `pi -e extensions/<name>.ts`
## Project Structure
- `extensions/` — Pi extension source files (.ts)
- `specs/` — Feature specifications
- `.pi/agents/` — Agent definitions for agent-team extension
- `.pi/agent-sessions/` — Ephemeral session files (gitignored)
## Conventions
- Extensions are standalone .ts files loaded by Pi's jiti runtime
- Available imports: `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@sinclair/typebox`, plus any deps in package.json
- Register tools at the top level of the extension function (not inside event handlers)
- Use `isToolCallEventType()` for type-safe tool_call event narrowing
+243
View File
@@ -0,0 +1,243 @@
# Claude Code vs Pi Agent — Feature Comparison
> Pi v0.52.10 vs Claude Code (Feb 2026)
---
## Design Philosophy
| Dimension | Claude Code | Pi Agent | Winner |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Core Mantra | "Tool for every engineer" — batteries-included, accessible to all skill levels | "If I don't need it, it won't be built" — minimal, opinionated, built for one engineer's workflow | Both |
| Approach to Features | Ship everything built-in (sub-agents, teams, MCP, plan mode, todos, web search, notebooks, 10+ tools) | Ship the minimum (4 tools, ~200-token prompt). Everything else is opt-in via extensions or bash | Both |
| Safety Philosophy | Safe by default — deny-first permissions, 5 modes, filesystem sandbox, Haiku pre-screening of commands | YOLO by default — no permissions, no sandbox. "Security in coding agents is mostly theater; if it can write and run code, it's game over" | Both |
| System Prompt Trust Model | Extensive guardrails (~10K tokens) — behavioral rules, formatting instructions, safety constraints, tool usage examples | Trust the model (~200 tokens) — "frontier models have been RL-trained up the wazoo, they inherently understand what a coding agent is" | Both |
| Observability | Abstracted — sub-agents are black boxes, compaction happens silently, system prompt not user-visible by default | Full transparency — every token visible, every tool call inspectable, no hidden orchestration, session HTML export | Pi |
| Context Engineering | Managed for you — auto-compaction, sub-agents handle overflow, system decides what enters context | User-controlled — minimal prompt overhead, no hidden injections, "exactly controlling what goes into context yields better outputs" | Pi |
| Extensibility Model | Shell hooks (external processes) + MCP protocol + Skills (markdown prompts) — loosely coupled, config-driven | TypeScript in-process extensions — same runtime, access full session state, block/modify/transform any event | Both |
| Target Audience | Every engineer — beginner-friendly, enterprise-ready, guided workflows, progressive disclosure | Power users — engineers who want control, understand tradeoffs, willing to build their own workflows | Both |
| Multi-Model Stance | Claude-first — optimized for Claude family, gateway workaround for others | Model-agnostic from day one — 324 models, 20+ providers, cross-provider context handoff, "we live in a multi-model world" | Pi |
| Planning Approach | Built-in plan mode — structured explore → plan → code phases, read-only mode, dedicated sub-agents | No plan mode — "just tell the agent to think with you." Write plans to files for persistence, versioning, and cross-session reuse | Both |
| Complexity Budget | Complexity lives in the harness so you don't have to think about it — more magic, less wiring | Complexity lives in your hands — minimal harness, you decide what to add and when. "Three similar lines of code is better than a premature abstraction" | Both |
---
## Cost & Licensing
| Feature | Claude Code | Pi Agent | Winner |
| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------ |
| Tool License | Proprietary | MIT (open source, fork/embed/self-host) | Pi |
| Subscription Cost | $20-200/mo required or Dedicated Anthropic API Keys | $0 (MIT, BYO API keys) | Pi |
| Cost Tracking | Available via /cost command, customizable via statusline configuration | Real-time $/token/cache display in footer per session and customizable via extensions | Both |
| Cost Optimization | 3 models at 3 price tiers (Opus > Sonnet > Haiku) — single provider | Mix cheap/expensive models per task across any provider, free tiers available | Pi |
| System Prompt Overhead | ~10,000+ tokens | ~200 tokens (more context for actual work) | Pi |
---
## Model & Provider Support
| Feature | Claude Code | Pi Agent | Winner |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| Official Providers | 4 platforms (Anthropic API, AWS Bedrock, Google Vertex, Foundry) — all serving Claude models | 20+ native (Anthropic, OpenAI, Google, Groq, xAI, OpenRouter, Azure, Bedrock, Vertex, Mistral, MiniMax, Kimi, Cerebras, ZAI, HuggingFace, custom) | Pi |
| Non-Anthropic / Self-Hosted Models | Via ANTHROPIC_BASE_URL gateway — routes to any OpenAI-compatible backend (OpenRouter, LiteLLM, local TGI, vLLM). Functional but unofficial workaround | Native first-class support for all providers + local (Ollama, vLLM, LM Studio via models.json). No proxy needed | Pi |
| Built-in Models | ~6 aliases (opus, sonnet, haiku, opusplan, sonnet[1m], default) mapping to Claude family | 324 (confirmed via ModelRegistry) across all providers | Pi |
| Model Switching Mid-Session | Yes — `/model <alias\|name>` command, `--model` flag at startup, ANTHROPIC_MODEL env var | Yes — Ctrl+P cycle, Ctrl+L fuzzy selector, session.setModel() in SDK | Tie |
| OAuth/Subscription Login | Anthropic subscriptions (Pro, Max, Teams, Enterprise) | Claude Pro, ChatGPT Plus, GitHub Copilot, Gemini CLI, Antigravity — all via /login and API keys | Pi |
| Thinking/Effort Levels | 3 effort levels (low/medium/high) on Opus 4.6 via `/model` slider, env var, or settings | 5 unified levels (off/minimal/low/medium/high) across ALL thinking capable models, Shift+Tab to cycle | Pi |
---
## Agent Harness
| Feature | Claude Code | Pi Agent | Winner |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Source Code | Closed source (proprietary) | Open source (MIT license) | Pi |
| System Prompt Size | ~10,000+ tokens (extensive tool descriptions, behavioral rules, safety guardrails) | ~200 tokens (minimal — trusts frontier models to code without hand-holding) | Pi |
| Default Tools | 10+ (Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, NotebookEdit, Task) | 4 (read, write, edit, bash) + 3 optional (grep, find, ls) | Both |
| Agent Architecture | Monorepo TypeScript CLI — single package with built-in tool execution, sub-agents, and team coordination | 4-package monorepo (pi-ai, pi-agent-core, pi-tui, pi-coding-agent) — modular separation of LLM abstraction, agent loop, TUI, and CLI | Both |
| Sub-Agent Support | Native Task tool — 7 parallel sub-agents, permission inheritance, typed agent roles (Explore, Plan, Bash, general-purpose) | None built-in, but available through extension that spawns separate pi processes in single/parallel/chain modes with different models per sub-agent | Claude Code |
| Agent Teams | Native team coordination (lead + workers, shared task lists, message passing, broadcast) | None built-in, but achievable through SDK orchestration scripts or RPC mode driving multiple pi processes | Claude Code |
| Default Permission Model | 5 modes (default, plan, acceptEdits, bypassPermissions, dontAsk) — deny-first with filesystem/network sandbox | None by default ("YOLO mode") — runs everything without asking. Permission-gate extension available but opt-in | Claude Code |
| Memory File | CLAUDE.md (project root, nested dirs, user-level) — auto-loaded, hierarchical | AGENTS.md — similar convention, compatible with ~/.claude/skills cross-tool standard | Tie |
| Cost Visibility | Available via /cost command, customizable via statusline configuration | Immediately visible in footer by default, further customizable via extensions and getSessionStats() API | Tie |
| Hooks System | Shell-command hooks (PreToolUse, PostToolUse, Stop, Notification) — external scripts, pass/fail | TypeScript extension events (20+ types) — in-process async handlers that block, modify, transform, access session state, render UI | Pi |
| Session Format | Linear conversation | JSONL tree with id/parentId (branching, forking, labels via /tree and /fork) | Pi |
| Extension State | No built-in state persistence for extensions | pi.appendEntry() persists custom data to session, survives restart | Pi |
---
## Tools & Capabilities
### Built-in Tools (Tool-by-Tool)
| Tool | Claude Code | Pi Agent | Winner |
| ----------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------- |
| Read | Built-in — reads files with optional offset/limit, images, PDFs, notebooks | Built-in (`read`) — reads files with optional range, auto-resizes images | Tie |
| Write | Built-in — creates or overwrites files | Built-in (`write`) — creates or overwrites files | Tie |
| Edit | Built-in — exact string replacement with replace_all option | Built-in (`edit`) — surgical find-and-replace, returns unified diff | Tie |
| Bash | Built-in — shell execution with timeout, background mode, description | Built-in (`bash`) — shell execution with streaming output and abort | Tie |
| Glob | Built-in — fast file pattern matching, sorted by modification time | Not built-in. Optional `find` tool available via `--tools` flag | Claude Code |
| Grep | Built-in — ripgrep-powered search with regex, context lines, output modes | Not built-in by default. Optional `grep` tool available via `--tools` flag | Tie |
| WebSearch | Built-in — web search with domain filtering, returns formatted results | Not built-in, customizable via extensions | Claude Code |
| WebFetch | Built-in — fetches URL content, converts HTML to markdown, AI processing | Not built-in, customizable via extensions | Claude Code |
| NotebookEdit | Built-in — Jupyter notebook cell editing (replace, insert, delete) | Not built-in, customizable via extensions | Claude Code |
| Task (Sub-agents) | Built-in — spawns typed sub-agents (Explore, Plan, Bash, general-purpose) with parallel execution | Not built-in, customizable via extensions. Subagent extension spawns separate pi processes | Claude Code |
| ls | Not a dedicated tool (use Bash or Glob) | Optional built-in (`ls`) via `--tools` flag | Tie |
### Tool System Capabilities
| Feature | Claude Code | Pi Agent | Winner |
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------- |
| Tool Observability | Sub-agent tool calls opaque | Every tool call, token, and dollar visible | Pi |
| Custom Tools | Via MCP servers (external process, JSON-RPC) | pi.registerTool() in-process TypeScript, streaming results, custom rendering | Pi |
| Tool Override | Not possible | Register tool with same name to replace built-in (e.g., audited read) | Pi |
| MCP Support | Native first-class, lazy loading (95% context reduction), OAuth | Not built-in (by design, argues 7-14k token overhead); available via extensions | Claude Code |
| Tool Count Philosophy | More tools = more capable out of the box (10+) | Fewer tools = smaller system prompt (~1000 tokens), trusts frontier models | Both |
---
## Hooks & Event System
> Claude Code: **14 hook events**, 3 handler types (command, prompt, agent) — shell-based, JSON stdin/stdout
> Pi: **25 extension events** across 7 categories — in-process TypeScript with full API access
### Architecture
| Feature | Claude Code | Pi Agent | Winner |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------ |
| Hook Language | Shell commands (any language), LLM prompts, or agent subprocesses | TypeScript (in-process, zero-build via jiti) | Pi |
| Handler Types | 3: command (shell), prompt (LLM eval), agent (multi-turn subagent) | 1: async TypeScript handler with full session/UI access | Both |
| Hook Configuration | JSON in settings files (.claude/settings.json, managed policy, plugin hooks.json, skill/agent frontmatter) | TypeScript code in extension files | Both |
| Can Modify Tool Input | Yes — updatedInput in PreToolUse/PermissionRequest | Yes — return modified args from tool_call handler | Tie |
| Async/Background Hooks | Yes — async: true on command hooks (non-blocking, results delivered next turn) | Yes — handlers are async by default, can fire-and-forget | Tie |
| Inter-Hook Communication | No built-in | Yes — pi.events shared event bus between extensions | Pi |
### Hook-by-Hook Mapping
| Lifecycle Point | Claude Code Hook | Pi Extension Event(s) | Notes |
| ----------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Session starts | `SessionStart` (matcher: startup/resume/clear/compact) | `session_start` | CC can persist env vars via CLAUDE_ENV_FILE. Both can inject context |
| User submits prompt | `UserPromptSubmit` — can block prompt, add context | `input` — can block, transform text, or handle entirely | Pi also distinguishes source: interactive/rpc/extension |
| Before tool executes | `PreToolUse` — allow/deny/ask, modify input | `tool_call` — block with reason, modify args, typed per-tool | Both can intercept and modify. Pi has typed narrowing via isToolCallEventType |
| Permission dialog shown | `PermissionRequest` — auto-allow/deny on behalf of user | N/A (Pi has no permission system by default) | CC-only — Pi runs YOLO by default, permission-gate is an extension |
| After tool succeeds | `PostToolUse` — feedback to Claude, modify MCP output | `tool_result` — modify results, log, transform output | Comparable |
| After tool fails | `PostToolUseFailure` — add context about the failure | `tool_result` (isError flag) | CC has a dedicated event; Pi uses same event with error flag |
| Tool execution streaming | N/A | `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | Pi-only — real-time streaming of tool execution progress |
| Bash spawn intercept | N/A | BashSpawnHook — modify command, cwd, env before bash executes | Pi-only — intercepts at process spawn level |
| User runs bash directly | N/A | `user_bash` — fired when user types shell commands (!! prefix) | Pi-only |
| Notification sent | `Notification` (matcher: permission_prompt/idle_prompt/auth_success/elicitation_dialog) | N/A (use ctx.ui.notify in any handler) | CC-only as a hook event |
| Subagent spawned | `SubagentStart` (matcher: agent type) | N/A (Pi has no built-in subagents) | CC-only |
| Subagent finished | `SubagentStop` — can prevent subagent from stopping | N/A | CC-only |
| Agent stops responding | `Stop` — can force Claude to continue | N/A (use turn_end or agent_end to react) | CC can block stopping; Pi can't but can queue follow-ups |
| Teammate goes idle | `TeammateIdle` — can force teammate to continue | N/A (Pi has no built-in teams) | CC-only |
| Task marked complete | `TaskCompleted` — can block completion | N/A (Pi has no built-in task system) | CC-only |
| Before compaction | `PreCompact` (matcher: manual/auto) | `session_before_compact` — can provide custom compaction entirely | Pi can fully replace compaction logic |
| After compaction | N/A | `session_compact` | Pi-only |
| Before session branching | N/A | `session_before_fork`, `session_before_switch`, `session_before_tree` | Pi-only — session tree architecture |
| After session branching | N/A | `session_fork`, `session_switch`, `session_tree` | Pi-only |
| Session ends | `SessionEnd` (matcher: clear/logout/exit/other) — no decision control | `session_shutdown` | Both fire on exit; neither can block |
| Before agent processes prompt | N/A | `before_agent_start` — can modify system prompt, images, prompt text | Pi-only — dynamic system prompt per-turn |
| Agent turn lifecycle | N/A | `agent_start`, `agent_end`, `turn_start`, `turn_end` | Pi-only — granular agent lifecycle |
| Message streaming | N/A | `message_start`, `message_update`, `message_end` | Pi-only — token-by-token streaming access |
| Model changed | N/A | `model_select` (source: set/cycle/restore) | Pi-only — react to model switches |
| Context window access | N/A | `context` — deep copy of messages, can filter/prune | Pi-only — direct context manipulation |
---
## Extensions & Customization
| Feature | Claude Code | Pi Agent | Winner |
| ------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | ------ |
| Extension Language | Shell scripts (hooks), Markdown (commands) | TypeScript (zero-build via jiti) | Pi |
| Slash Commands | .claude/commands/*.md prompt templates | Prompt templates + /skill:name + extension-registered commands | Tie |
| Package Distribution | Plugin marketplace — `/plugin` commands, git-based sharing | pi install npm:/git:/local, pi config TUI, npm gallery | Both |
| Skills (Agent Skills Standard) | Yes (auto-invocation) | Yes (progressive disclosure, cross-tool compat with ~/.claude/skills) | Tie |
| Themes | Minimally customizable | 51 color tokens, hot-reload, dark/light built-in, community themes via packages | Pi |
| Custom Keyboard Shortcuts | ~/.claude/keybindings.json | pi.registerShortcut() in extensions | Tie |
| Custom CLI Flags | Not possible | pi.registerFlag() adds custom flags to CLI | Pi |
| Custom Providers | Not possible | pi.registerProvider() with OAuth support | Pi |
| Custom Editors | Not possible | Modal editor (vim), emacs bindings, rainbow editor via extensions | Pi |
---
## UI & Terminal
| Feature | Claude Code | Pi Agent | Winner |
| --------------- | --------------------------------------- | ------------------------------------------------------------------------- | ------ |
| Custom Header | No | ctx.ui.setHeader() replaces logo/keybinding hints with custom component | Pi |
| Custom Footer | Configurable statusline (tokens, cost, model) | ctx.ui.setFooter() with git branch, token stats, cost tracking, anything | Pi |
| Status Line | Configurable statusline (tokens, cost, model) | ctx.ui.setStatus() with themed colors, turn tracking, custom data | Pi |
| Widgets | No | ctx.ui.setWidget() above/below editor with custom content | Pi |
| Overlays | No | Full overlay applications (Doom, Space Invaders, QA test overlays) | Pi |
| Dialogs | Basic permission prompts | ctx.ui.select(), confirm(), input(), editor() + custom rendering | Pi |
| Rendering | Standard terminal with known issues | Standard terminal with known issues | Both |
| Message Queuing | Supported — queue messages while agent works | Enter = steer (interrupt), Alt+Enter = follow-up (queue after completion) | Both |
---
## Programmatic & SDK
| Feature | Claude Code | Pi Agent | Winner |
| -------------------- | ------------------------------ | ----------------------------------------------------------------------------- | ------ |
| Non-Interactive Mode | claude --print | pi -p (+ stdin auto-activates) | Tie |
| JSON Streaming | --output-format stream-json | --mode json (JSONL events with full lifecycle) | Tie |
| RPC Mode | None | --mode rpc (26+ commands, bidirectional JSON protocol, any language) | Pi |
| Node.js SDK | @anthropic-ai/claude-agent-sdk | @mariozechner/pi-coding-agent (createAgentSession, full internal API) | Tie |
| Mid-Stream Control | ClaudeSDKClient.interrupt() — stop and redirect | steer() interrupts, followUp() queues messages while agent works | Pi |
| Session Stats API | Limited | getSessionStats() returns tokens (in/out/cache), cost, tool calls per session | Pi |
| HTML Export | /export — session to HTML | --export, /export, session.exportToHtml() | Both |
| SDK Examples | Docs-based | 12 official examples from minimal to full-control in package | Pi |
---
## Multi-Agent & Orchestration
| Feature | Claude Code | Pi Agent | Winner |
| ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | ----------- |
| Sub-Agents | Native Task tool, 7 parallel, permission inheritance | Subagent extension (single/parallel/chain modes), spawns separate pi processes | Claude Code |
| Agent Teams | Native team coordination (lead + workers) | No built-in equivalent; use orchestration scripts | Claude Code |
| Multi-Model Orchestration | Not possible (single provider) | Different models per sub-agent (scout on flash, worker on opus) | Pi |
---
## Enterprise & Platform
| Feature | Claude Code | Pi Agent | Winner |
| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------ | ----------- |
| IDE Integration | VS Code, JetBrains, Cursor (inline diffs, @mentions) | Terminal-only (could integrate via RPC) | Claude Code |
| Web/Mobile/Desktop | claude.ai/code, iOS app, desktop app | Terminal only | Claude Code |
| Enterprise SSO/Audit | Yes (SSO, MFA, audit logs, admin dashboard) | No | Claude Code |
| Permissions/Sandboxing | 5 modes, deny-first rules, filesystem/network sandbox | None by default ("YOLO mode"); permission-gate extension available | Claude Code |
| Git Integration | Deep (commits, PRs, merge conflicts, GitHub Actions, GitLab CI) | Via bash; git-checkpoint extension available | Claude Code |
| Slack/Chat Integration | Native @Claude mentions to PRs | pi-mom Slack bot package | Claude Code |
---
## Sharing & Distribution
| Feature | Claude Code | Pi Agent | Winner |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------- |
| Package System | Plugin marketplace — `/plugin` commands, `.claude-plugin/plugin.json` manifest | `pi install npm:/git:/local``package.json` with `pi` key, `pi-package` npm keyword | Both |
| What's Bundled | Skills, agents, hooks, MCP servers, LSP servers | Extensions, skills, prompt templates, themes | Both |
| Distribution Sources | Marketplace (GitHub repo, git URL, npm, pip, direct URL, local path) | npm registry, git (GitHub/GitLab/SSH), local paths — no intermediate marketplace needed | Both |
| Discovery | Official `claude-plugins-official` marketplace + team/community marketplaces via `extraKnownMarketplaces` | npm search (`pi-package` keyword) + gallery at shittycodingagent.ai/packages with video/image previews | Both |
| Scope | User, project, local, managed (enterprise) — namespaced as `plugin-name:skill-name` | Global (`~/.pi/`) or project (`.pi/`, `-l` flag) — project settings auto-install missing packages on startup | Tie |
| Config UI | `/plugin` slash commands for install/browse/manage | `pi config` interactive TUI for enable/disable per-resource | Tie |
| Try Without Installing | No equivalent | `pi -e npm:@foo/bar` — ephemeral install for current session only | Pi |
| Cross-Tool Portability | Agent Skills standard (agentskills.io) — shared with VS Code, Codex, Cursor, GitHub | No cross-tool standard — Pi-specific extensions | Claude Code |
| Enterprise Controls | `strictKnownMarketplaces`, allowlists by repo/URL/host regex, managed plugin deployment | No enterprise controls — trust-based, review source before installing | Claude Code |
| Git-Based Sharing | `.claude/` directory (settings, skills, agents, rules, hooks) committed to repo — team gets config on clone | `.pi/settings.json` with packages — team gets packages auto-installed on startup | Tie |
| Update Mechanism | Marketplace auto-updates at startup (configurable) | `pi update` for non-pinned packages, version pinning with `@version` | Tie |
| Package Filtering | Plugin resources loaded as-is (namespaced to prevent conflicts) | Glob patterns + `!exclusions` per resource type, force-include/exclude exact paths | Pi |
---
## Community & Ecosystem
| Feature | Claude Code | Pi Agent |
| ---------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Creator | Anthropic — $1B+ ARR, enterprise AI company | Mario Zechner — libGDX creator (24.8K stars), solo maintainer |
| Traction | Enterprise adoption, deep IDE integrations, massive user base | 11.5K stars, 3.17M monthly npm downloads, 208 versions |
| Endorsements | Enterprise customers, Anthropic ecosystem | Armin Ronacher (Flask/Ruff) uses + contributes, powers OpenClaw (145K stars) |
| Release Velocity | Regular releases | 10+ releases in 8 days, new model support within hours |
+178
View File
@@ -0,0 +1,178 @@
# Pi Agent vs OpenCode — Customization & Control Comparison
> Pi v0.52+ vs OpenCode v1.1+ (Feb 2026)
>
> **Thesis:** Pi and OpenCode are both MIT-licensed, open-source, model-agnostic terminal coding agents. But they represent fundamentally different architectures. Pi is a **programmable platform** — a minimal harness with 25+ in-process TypeScript hooks that let you build your own agent experience. OpenCode is a **configurable product** — a full-featured Claude Code alternative with JSON-driven settings and a plugin system for extras. The distinction matters: Pi gives you control at the *runtime* level. OpenCode gives you control at the *configuration* level.
---
## The Core Architectural Split
| Dimension | Pi Agent | OpenCode |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **What it ships** | 4 tools, ~200-token system prompt, 25+ extension events. Everything else is opt-in. | 12+ tools, built-in sub-agents, Plan mode, LSP, web search, MCP, permissions, desktop app. |
| **Extension model** | In-process TypeScript. Extensions run in the same runtime as the agent loop. They can intercept, block, modify, and transform any event in real-time. | Out-of-process plugins. JS/TS files in a config directory that subscribe to events and register tools. |
| **Customization ceiling** | Effectively unlimited — you can replace the entire UI, override any tool, inject custom system prompts per-turn, build full overlay applications (Doom, Space Invaders, QA tools), and orchestrate multi-agent pipelines. | Bounded — you can add tools, hook into tool execution, customize compaction, and configure permissions via JSON. But you cannot modify the TUI, inject custom UI components, or intercept the input/agent lifecycle at the same depth. |
| **Philosophy** | "If I don't need it, it won't be built. Build what you need." | "Ship a polished, complete product. Configure what you need." |
| **Closest analogy** | A race car chassis + engine. You design the body, aero, and electronics. | A production car with a tuning package. You adjust settings and bolt on accessories. |
---
## Extension / Plugin System — Deep Comparison
This is the single most important comparison between these two tools. Pi's extension system is architecturally different from OpenCode's plugin system — not just in API surface, but in *where code runs* and *what it can touch*.
### Architecture
| Feature | Pi Extensions | OpenCode Plugins | Winner |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| Runtime model | **In-process** — extensions execute in the same Bun/Node.js runtime as the agent loop. Zero serialization overhead. Direct access to session state, UI context, and the event stream. | **Separate module** — plugins are loaded from `.opencode/plugins/` or npm. They receive a context object and return hook handlers. Communication happens through the SDK client. | Pi |
| Build step | None — TypeScript executed via jiti at runtime. Write `.ts`, run immediately. | None — TypeScript supported natively via Bun loader. | Tie |
| Composability | Stack multiple extensions with `-e` flags: `pi -e ext1.ts -e ext2.ts`. Extensions can communicate via `pi.events` shared bus. | Multiple plugins loaded from directory. No built-in inter-plugin communication channel. | Pi |
| Ephemeral testing | `pi -e npm:@foo/bar` — try a package without installing | Not possible — must add to config or plugin directory | Pi |
### Event Coverage
This is where the gap is widest. Pi exposes 25+ typed events across 7 categories. OpenCode exposes ~20 events but skips critical lifecycle hooks.
| Lifecycle Point | Pi Extension Event | OpenCode Plugin Hook | Gap Analysis |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Session starts** | `session_start` | `session.created` | Comparable |
| **User submits prompt** | `input` — can **block**, transform text, or handle entirely | ❌ Not available | **Pi-only.** This is huge — Pi can gate all user input, inject context, redirect prompts, or prevent execution before the agent ever sees it. OpenCode has no equivalent. |
| **Before agent processes prompt** | `before_agent_start` — can modify system prompt, images, prompt text **per-turn** | ❌ Not available | **Pi-only.** Dynamic system prompt injection on every turn. The purpose-gate extension uses this to inject session intent into every agent call. |
| **Agent turn lifecycle** | `agent_start`, `agent_end`, `turn_start`, `turn_end` | `session.idle` (only fires when agent finishes) | **Pi has 4x granularity.** OpenCode only knows when the agent is done, not when turns start/end within a session. |
| **Before tool executes** | `tool_call` — block with reason, modify args, typed per-tool via `isToolCallEventType()` | `tool.execute.before` — can modify args, throw to block | Both can intercept. Pi has typed narrowing per tool (bash, read, write, edit). |
| **After tool executes** | `tool_result` — modify results, log, transform output | `tool.execute.after` — react to results | Comparable |
| **Tool execution streaming** | `tool_execution_start`, `_update`, `_end` | ❌ Not available | **Pi-only.** Real-time streaming of tool output as it happens — critical for building live progress UIs. |
| **Bash spawn intercept** | `BashSpawnHook` — modify command, cwd, env vars **before the process spawns** | `shell.env` — inject env vars into shell execution | Pi intercepts at process spawn level. OpenCode only injects env vars. |
| **Message streaming** | `message_start`, `message_update`, `message_end` — token-by-token access | `message.part.updated`, `message.updated` | Both have message events; Pi is more granular with token-level streaming. |
| **Model changed** | `model_select` (source: set/cycle/restore) | ❌ Not available | **Pi-only.** React to model switches programmatically. |
| **Context window access** | `context` — deep copy of all messages, can filter and prune | ❌ Not available | **Pi-only.** Direct manipulation of what's in the context window. No other tool offers this. |
| **Before compaction** | `session_before_compact` — can **replace compaction logic entirely** | `experimental.session.compacting` — inject context or replace prompt | Both can customize. Pi can replace the entire compaction flow; OpenCode can replace the prompt. |
| **Session branching** | `session_before_fork`, `session_fork`, `session_before_switch`, `session_switch`, `session_before_tree`, `session_tree` | ❌ Not applicable (no branching model) | **Pi-only.** Pi's JSONL tree session format supports forking/branching. OpenCode uses linear SQLite sessions. |
| **Permission events** | Not applicable (YOLO by default) | `permission.asked`, `permission.replied` | OpenCode-only — but Pi can build equivalent or better permission systems via `tool_call` blocking. |
| **LSP events** | Not built-in | `lsp.client.diagnostics`, `lsp.updated` | OpenCode-only — native LSP integration. |
| **File watcher** | Not built-in | `file.watcher.updated` | OpenCode-only. |
| **Todo events** | Not built-in | `todo.updated` | OpenCode-only. |
**Summary: Pi has 8+ hook points that OpenCode simply doesn't expose**, including the critical `input`, `before_agent_start`, agent lifecycle, tool execution streaming, context window access, and session branching hooks. These aren't minor — they're the hooks you need to build fundamentally different agent behaviors.
### UI Customization
This is the other dimension where Pi is in a different category entirely.
| Feature | Pi Extensions | OpenCode Plugins | Winner |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------ |
| Custom header | `ctx.ui.setHeader()` — replace the logo and keybinding hints with any content | ❌ Not possible | Pi |
| Custom footer | `ctx.ui.setFooter()` — git branch, token stats, cost tracking, tool counters, anything | ❌ Not possible | Pi |
| Status line | `ctx.ui.setStatus()` — themed colors, turn tracking, custom data | ❌ Not possible | Pi |
| Widgets | `ctx.ui.setWidget(key, renderFn)` — persistent UI panels above/below the editor. Used for subagent progress, task lists, tool counters, purpose display. | ❌ Not possible | Pi |
| Overlays | Full overlay applications — session replay timeline, game overlays (Doom), QA tools | ❌ Not possible | Pi |
| Dialogs | `ctx.ui.select()`, `confirm()`, `input()`, `editor()` — interactive prompts with custom rendering | ❌ Not available to plugins (only built-in permission dialogs) | Pi |
| Custom editors | vim modal editor, emacs bindings, rainbow editor — all via extensions | ❌ Not possible | Pi |
| Notifications | `ctx.ui.notify()` from any handler | `osascript` or desktop app notifications via plugin | Both |
| Theme system | 51 color tokens, hot-reload, dark/light, custom themes via packages | Theme customization via `tui.json` | Pi |
**OpenCode's TUI is polished but closed.** It's built with Bubble Tea (Go) and the Ink React renderer, and it looks great out of the box. But you can't inject custom UI components, widgets, or overlays into it from a plugin. What you see is what you get.
**Pi's TUI is a canvas.** The extensions API gives you full control over every UI surface — header, footer, status line, widgets above/below the editor, fullscreen overlays, and interactive dialogs. The pi-vs-claude-code repo demonstrates this with 16 extensions that completely transform the agent experience.
### Registration APIs
| What you can register | Pi | OpenCode |
| ----------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
| Custom tools | `pi.registerTool()` — in-process, streaming results, custom rendering | `tool()` helper in plugins — Zod schema, execute function | Tie |
| Override built-in tools | Register tool with same name → replaces built-in | Plugin tool with same name → takes precedence | Tie |
| Custom slash commands | `pi.registerCommand()` + prompt templates in `.pi/prompts/` | Markdown files in `.opencode/commands/` | Tie |
| Custom CLI flags | `pi.registerFlag()` — adds flags to the `pi` CLI | ❌ Not possible | Pi |
| Custom keyboard shortcuts | `pi.registerShortcut()` | Configurable via `tui.json` keybinds (JSON, not code) | Pi |
| Custom providers | `pi.registerProvider()` with OAuth support | JSON config in `opencode.json` with npm AI SDK packages | Pi |
| Persistent extension state | `pi.appendEntry()` — survives restarts, stored in session JSONL | ❌ Not available to plugins | Pi |
| Inter-extension communication | `pi.events` shared event bus | ❌ Not available | Pi |
---
## What OpenCode Does Better (And Why It Still Matters)
The thesis isn't "Pi is better." It's "Pi is more controllable." OpenCode has real advantages that come from its product-first approach:
| Feature | Why OpenCode Wins | Pi's Alternative |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Batteries included** | 12+ tools, LSP, web search, MCP, permissions, todo tracking — zero configuration needed. You install and start coding. | You build what you need with extensions, or install pi packages. Higher ceiling, higher floor. |
| **Built-in Plan mode** | Tab to switch between Build (full access) and Plan (read-only). No setup. | No plan mode. "Just tell the agent to think." Or build it with an extension. |
| **Native MCP support** | stdio and remote MCP servers configured in JSON. First-class integration. | Not built-in by design (argues 7-14K token overhead). Available via extensions. |
| **Permission system** | Granular allow/deny/ask with glob patterns per tool, per agent, per command pattern. `.env` files denied by default. Doom loop detection. | YOLO by default. The damage-control extension builds equivalent functionality — but you have to build or install it. |
| **LSP integration** | go-to-definition, find references, hover, document symbols, call hierarchy — all built in. | Not available. Would need to be built as an extension. |
| **Client/server architecture** | TUI is one client. Desktop app, VS Code extension, web UI, and mobile are others. `opencode serve` exposes an HTTP API. | Terminal only. RPC mode over stdin/stdout for programmatic access, but no multi-client architecture. |
| **Desktop app** | Native app for macOS, Windows, Linux. | No. |
| **GitHub/GitLab integration** | Comment `@opencode` on issues/PRs. GitLab Duo OAuth. | Via bash. |
| **Massive community** | 104K stars, 735 contributors, 9,200+ commits. Issues get fixed fast. | ~8.9K stars (pi-mono), solo maintainer + approved contributors. Smaller but passionate community. |
| **Organizational config** | `.well-known/opencode` endpoint for enterprise defaults. | Not available. |
---
## Concrete Examples: What Pi's Control Enables
These aren't theoretical. They're actual extensions in the pi-vs-claude-code repo.
### 1. Purpose Gate (Input Interception + Dynamic System Prompts + Custom Widgets)
Forces the engineer to declare session intent before any work begins. Uses `input` to block all prompts until purpose is set, `before_agent_start` to inject purpose into the system prompt on every turn, and `setWidget` to display it persistently.
**Could OpenCode do this?** Partially. You could create a custom agent with a specific prompt, but you can't block user input, you can't inject per-turn system prompts, and you can't add a persistent widget to the UI.
### 2. Damage Control (Safety Auditing via tool_call Interception)
Intercepts every tool call, checks against YAML-defined rules (dangerous bash patterns, zero-access paths, read-only paths, no-delete paths), and blocks or prompts for confirmation. Uses typed narrowing (`isToolCallEventType`) to extract tool-specific args.
**Could OpenCode do this?** Partially — OpenCode's `tool.execute.before` can throw to block tool calls, and the built-in permission system covers common cases. But Pi's version is fully programmable with custom rules and custom UI (confirm dialogs, status line updates, persistent logging via `appendEntry`).
### 3. Subagent Widget (Multi-Agent Orchestration with Live UI)
`/sub` spawns background Pi processes as sub-agents. Each gets its own persistent session file, live-streaming progress widget, and independent model. `/subcont` continues a subagent's conversation. Widgets stack in the UI showing real-time status.
**Could OpenCode do this?** OpenCode has built-in subagents (@general, custom agents), but you can't add live-updating widgets to the UI, you can't show streaming progress from multiple agents simultaneously, and you can't continue a specific subagent's conversation across turns.
### 4. Agent Team (Dispatcher Orchestration with Grid Dashboard)
The primary agent becomes a pure dispatcher — it reads your prompt, picks a specialist from a YAML roster, and delegates via a `dispatch_agent` tool. A grid dashboard widget shows all agents and their status.
**Could OpenCode do this?** OpenCode has custom agents, but the dispatcher pattern with a grid dashboard UI and real-time status widgets isn't possible through plugins.
### 5. Theme Cycler (Full TUI Theming)
Ctrl+X/Ctrl+Q keyboard shortcuts cycle through custom themes. `/theme` command opens a selector. Hot-reload with 51 color tokens.
**Could OpenCode do this?** OpenCode has themes via `tui.json`, but no keyboard shortcuts for cycling and no extension-level theme registration.
---
## The OpenCode Clone Thesis
You asked whether OpenCode is more of a "Claude Code copycat." The evidence supports this framing. Here's what Claude Code ships vs. what OpenCode ships vs. what Pi ships:
| Feature | Claude Code | OpenCode | Pi |
| --------------------- | ------------------------------ | ----------------------------------- | -------------------------------- |
| Built-in sub-agents | ✅ Native Task tool, 7 parallel | ✅ General subagent + custom agents | ❌ Build with extensions |
| Plan mode | ✅ Built-in plan mode | ✅ Tab to switch to Plan agent | ❌ "Just tell the agent to think" |
| Permission system | ✅ 5 modes, deny-first | ✅ allow/deny/ask with glob patterns | ❌ YOLO by default |
| MCP support | ✅ Native, first-class | ✅ Native, stdio + remote | ❌ Not built-in |
| Web search | ✅ Built-in | ✅ Built-in (Exa AI) | ❌ Build with extensions |
| LSP integration | ❌ Not built-in | ✅ Native | ❌ Not built-in |
| IDE extensions | ✅ VS Code, JetBrains | ✅ VS Code | ❌ Terminal only |
| Desktop app | ✅ Desktop app | ✅ Desktop app (beta) | ❌ Terminal only |
| AGENTS.md / CLAUDE.md | ✅ CLAUDE.md | ✅ AGENTS.md | ✅ AGENTS.md (or CLAUDE.md) |
| Slash commands | ✅ .claude/commands/ | ✅ .opencode/commands/ | ✅ .pi/prompts/ + extensions |
| Skills | ✅ Agent Skills standard | ✅ SKILL.md loading | ✅ Agent Skills standard |
| Session sharing | ✅ /export to HTML | ✅ /share creates link | ✅ /export to HTML |
| GitHub Actions bot | ✅ Native | ✅ @opencode bot | ❌ Not built-in |
| Todo tracking | ✅ Built-in | ✅ Built-in | ❌ Build with extensions |
OpenCode systematically reproduced Claude Code's feature set, added LSP support and a desktop app, swapped the single-provider lock-in for model-agnostic support, and open-sourced it under MIT. It's a very good execution of this strategy.
Pi rejected the feature set entirely and built a minimal, extensible harness that trusts the engineer to compose their own agent experience. It's a different bet — that the right set of primitives (events, UI APIs, tool registration, session branching) is more valuable than a pre-built feature set.
---
## Summary: The Decision Framework
**Choose Pi if you are building a custom agent workflow** — you want to control input interception, dynamic system prompts, UI components, multi-agent orchestration with live dashboards, safety auditing with custom rules, or anything that requires programmatic control over the agent loop. Pi's extensions are code that runs inside the agent. The ceiling is whatever you can build in TypeScript.
**Choose OpenCode if you want a production-ready Claude Code replacement** — you need LSP, MCP, permissions, Plan mode, sub-agents, web search, GitHub integration, a desktop app, and a massive community shipping updates daily. OpenCode's plugin system handles common customization needs (tool hooks, custom tools, notifications, compaction), and the JSON-driven config covers agent definitions, permissions, and provider setup without writing code.
**The one-line version:** Pi is a platform. OpenCode is a product. Both are open source. The question is whether you want to *build* your experience or *configure* an existing Claude Code like experience.
+264 -21
View File
@@ -1,36 +1,279 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# pi-vs-cc
## Getting Started
A collection of [Pi Coding Agent](https://github.com/mariozechner/pi-coding-agent) customized instances. _Why?_ To showcase what it looks like to hedge against the leader in the agentic coding market, Claude Code. Here we showcase how you can customize the UI, agent orchestration tools, safety auditing, and cross-agent integrations.
First, run the development server:
<div align="center">
<img src="./images/pi-logo.png" alt="pi-vs-cc" width="700">
</div>
---
## Prerequisites
All three are required:
| Tool | Purpose | Install |
| --------------- | ------------------------- | ---------------------------------------------------------- |
| **Bun** ≥ 1.3.2 | Runtime & package manager | [bun.sh](https://bun.sh) |
| **just** | Task runner | `brew install just` |
| **pi** | Pi Coding Agent CLI | [Pi docs](https://github.com/mariozechner/pi-coding-agent) |
---
## API Keys
Pi does **not** auto-load `.env` files — API keys must be present in your shell's environment **before** you launch Pi. A sample file is provided:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
cp .env.sample .env # copy the template
# open .env and fill in your keys
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
`.env.sample` covers the four most popular providers:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
| Provider | Variable | Get your key |
| ---------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
| OpenAI | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/api-keys) |
| Anthropic | `ANTHROPIC_API_KEY` | [console.anthropic.com](https://console.anthropic.com/settings/keys) |
| Google | `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
| OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai](https://openrouter.ai/keys) |
| Many Many Others | `***` | [Pi Providers docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) |
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
### Sourcing your keys
## Learn More
Pick whichever approach fits your workflow:
To learn more about Next.js, take a look at the following resources:
**Option A — Source manually each session:**
```bash
source .env && pi
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
**Option B — One-liner alias (add to `~/.zshrc` or `~/.bashrc`):**
```bash
alias pi='source $(pwd)/.env && pi'
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
**Option C — Use the `just` task runner (auto-wired via `set dotenv-load`):**
```bash
just pi # .env is loaded automatically for every just recipe
just ext-minimal # works for all recipes, not just `pi`
```
## Deploy on Vercel
---
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Installation
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```bash
bun install
```
---
## Extensions
| Extension | File | Description |
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **pure-focus** | `extensions/pure-focus.ts` | Removes the footer bar and status line entirely — pure distraction-free mode |
| **minimal** | `extensions/minimal.ts` | Compact footer showing model name and a 10-block context usage meter `[###-------] 30%` |
| **cross-agent** | `extensions/cross-agent.ts` | Scans `.claude/`, `.gemini/`, `.codex/` dirs for commands, skills, and agents and registers them in Pi |
| **purpose-gate** | `extensions/purpose-gate.ts` | Prompts you to declare session intent on startup; shows a persistent purpose widget and blocks prompts until answered |
| **tool-counter** | `extensions/tool-counter.ts` | Rich two-line footer: model + context meter + token/cost stats on line 1, cwd/branch + per-tool call tally on line 2 |
| **tool-counter-widget** | `extensions/tool-counter-widget.ts` | Live-updating above-editor widget showing per-tool call counts with background colors |
| **subagent-widget** | `extensions/subagent-widget.ts` | `/sub <task>` command that spawns background Pi subagents; each gets its own streaming live-progress widget |
| **tilldone** | `extensions/tilldone.ts` | Task discipline system — define tasks before starting work; tracks completion state across steps; shows persistent task list in footer with live progress |
| **agent-team** | `extensions/agent-team.ts` | Dispatcher-only orchestrator: the primary agent delegates all work to named specialist agents via `dispatch_agent`; shows a grid dashboard |
| **system-select** | `extensions/system-select.ts` | `/system` command to interactively switch between agent personas/system prompts from `.pi/agents/`, `.claude/agents/`, `.gemini/agents/`, `.codex/agents/` |
| **damage-control** | `extensions/damage-control.ts` | Real-time safety auditing — intercepts dangerous bash patterns and enforces path-based access controls from `.pi/damage-control-rules.yaml` |
| **agent-chain** | `extensions/agent-chain.ts` | Sequential pipeline orchestrator — chains multiple agents where each step's output feeds into the next step's prompt; use `/chain` to select and run |
| **agent-dashboard** | `extensions/agent-dashboard.ts` | Unified agent observability — passively tracks `dispatch_agent`, `subagent_create`, and `run_chain` across all orchestration interfaces; compact widget + `/dashboard` overlay with live, history, interface, and stats views |
| **pi-pi** | `extensions/pi-pi.ts` | Meta-agent that builds Pi agents using parallel research experts for documentation |
| **session-replay** | `extensions/session-replay.ts` | Scrollable timeline overlay of session history - showcasing customizable dialog UI |
| **theme-cycler** | `extensions/theme-cycler.ts` | Keyboard shortcuts (Ctrl+X/Ctrl+Q) and `/theme` command to cycle/switch between custom themes |
---
## Usage
### Run a single extension
```bash
pi -e extensions/<name>.ts
```
### Stack multiple extensions
Extensions compose — pass multiple `-e` flags:
```bash
pi -e extensions/minimal.ts -e extensions/cross-agent.ts
```
### Use `just` recipes
`just` wraps the most useful combinations. Run `just` with no arguments to list all available recipes:
```bash
just
```
Common recipes:
```bash
just pi # Plain Pi, no extensions
just ext-pure-focus # Distraction-free mode
just ext-minimal # Minimal context meter footer
just ext-cross-agent # Cross-agent command loading + minimal footer
just ext-purpose-gate # Purpose gate + minimal footer
just ext-tool-counter # Rich two-line footer with tool tally
just ext-tool-counter-widget # Per-tool widget above the editor
just ext-subagent-widget # Subagent spawner with live progress widgets
just ext-tilldone # Task discipline system with live progress tracking
just ext-agent-team # Multi-agent orchestration grid dashboard
just ext-system-select # Agent persona switcher via /system command
just ext-damage-control # Safety auditing + minimal footer
just ext-agent-chain # Sequential pipeline orchestrator with step chaining
just ext-agent-dashboard # Unified agent monitoring across team, subagent, and chain
just ext-pi-pi # Meta-agent that builds Pi agents using parallel experts
just ext-session-replay # Scrollable timeline overlay of session history
just ext-theme-cycler # Theme cycler + minimal footer
just all # Open every extension in its own terminal window
```
The `open` recipe allows you to spin up a new terminal window with any combination of stacked extensions (omit `.ts`):
```bash
just open purpose-gate minimal tool-counter-widget
```
---
## Project Structure
```
pi-vs-cc/
├── extensions/ # Pi extension source files (.ts) — one file per extension
├── specs/ # Feature specifications for extensions
├── .pi/
│ ├── agent-sessions/ # Ephemeral session files (gitignored)
│ ├── agents/ # Agent definitions for team and chain extensions
│ │ ├── pi-pi/ # Expert agents for the pi-pi meta-agent
│ │ ├── agent-chain.yaml # Pipeline definition for agent-chain
│ │ ├── teams.yaml # Team definition for agent-team
│ │ └── *.md # Individual agent persona/system prompts
│ ├── skills/ # Custom skills
│ ├── themes/ # Custom themes (.json) used by theme-cycler
│ ├── damage-control-rules.yaml # Path/command rules for safety auditing
│ └── settings.json # Pi workspace settings
├── justfile # just task definitions
├── CLAUDE.md # Conventions and tooling reference (for agents)
├── THEME.md # Color token conventions for extension authors
└── TOOLS.md # Built-in tool function signatures available in extensions
```
---
## Orchestrating Multi-Agent Workflows
Pi's architecture makes it easy to coordinate multiple autonomous agents. This playground includes several powerful multi-agent extensions:
### Subagent Widget (`/sub`)
The `subagent-widget` extension allows you to offload isolated tasks to background Pi agents while you continue working in the main terminal. Typing `/sub <task>` spawns a headless subagent that reports its streaming progress via a persistent, live-updating UI widget above your editor.
### Agent Teams (`/team`)
The `agent-team` orchestrator operates as a dispatcher. Instead of answering prompts directly, the primary agent reviews your request, selects a specialist from a defined roster, and delegates the work via a `dispatch_agent` tool.
- Teams are configured in `.pi/agents/teams.yaml` where each top-level key is a team name containing a list of agent names (e.g., `frontend: [planner, builder, bowser]`).
- Individual agent personas (e.g., `builder.md`, `reviewer.md`) live in `.pi/agents/`.
- **pi-pi Meta-Agent**: The `pi-pi` team specifically delegates tasks to specialized Pi framework experts (`ext-expert.md`, `theme-expert.md`, `tui-expert.md`) located in `.pi/agents/pi-pi/` to build high-quality Pi extensions using parallel research.
- **Web Crawling Fallbacks**: To ingest the latest framework documentation dynamically, these experts use `firecrawl` as their default modern page crawler, but are explicitly programmed to safely fall back to the native `curl` baked into their bash toolset if Firecrawl fails or is unavailable.
### Agent Chains (`/chain`)
Unlike the dynamic dispatcher, `agent-chain` acts as a sequential pipeline orchestrator. Workflows are defined in `.pi/agents/agent-chain.yaml` where the output of one agent becomes the input (`$INPUT`) to the next.
- Workflows are defined as a list of `steps`, where each step specifies an `agent` and a `prompt`.
- The `$INPUT` variable injects the previous step's output (or the user's initial prompt for the first step), and `$ORIGINAL` always contains the user's initial prompt.
- Example: The `plan-build-review` pipeline feeds your prompt to the `planner`, passes the plan to the `builder`, and finally sends the code to the `reviewer`.
### Agent Dashboard (`/dashboard`)
The `agent-dashboard` extension provides unified observability across all three orchestration interfaces. It passively intercepts `dispatch_agent`, `subagent_create`, `subagent_continue`, and `run_chain` tool calls and tracks every agent run. Stack it alongside any orchestration extension:
```bash
pi -e extensions/agent-team.ts -e extensions/agent-dashboard.ts
```
The compact widget shows active/done/error counts. Use `/dashboard` to open a full-screen overlay with four views: **Live** (active agent cards), **History** (completed runs table), **Interfaces** (grouped by team/subagent/chain), and **Stats** (aggregate metrics and per-agent durations).
---
## Safety Auditing & Damage Control
The `damage-control` extension provides real-time security hooks to prevent catastrophic mistakes when agents execute bash commands or modify files. It uses Pi's `tool_call` event to intercept and evaluate every action against `.pi/damage-control-rules.yaml`.
- **Dangerous Commands**: Uses regex (`bashToolPatterns`) to block destructive commands like `rm -rf`, `git reset --hard`, `aws s3 rm --recursive`, or `DROP DATABASE`. Some rules strictly block execution, while others (`ask: true`) pause execution to prompt you for confirmation.
- **Zero Access Paths**: Prevents the agent from reading or writing sensitive files (e.g., `.env`, `~/.ssh/`, `*.pem`).
- **Read-Only Paths**: Allows reading but blocks modifying system files or lockfiles (`package-lock.json`, `/etc/`).
- **No-Delete Paths**: Allows modifying but prevents deleting critical project configuration (`.git/`, `Dockerfile`, `README.md`).
---
## Extension Author Reference
Companion docs cover the conventions used across all extensions in this repo:
- **[COMPARISON.md](COMPARISON.md)** — Feature-by-feature comparison of Claude Code vs Pi Agent across 12 categories (design philosophy, tools, hooks, SDK, enterprise, and more).
- **[PI_VS_OPEN_CODE.md](PI_VS_OPEN_CODE.md)** — Architectural comparison of Pi Agent vs OpenCode (open-source Claude Code alternative) focusing on extension capabilities, event lifecycle, and UI customization.
- **[RESERVED_KEYS.md](RESERVED_KEYS.md)** — Pi reserved keybindings, overridable keys, and safe keys for extension authors.
- **[THEME.md](THEME.md)** — Color language: which Pi theme tokens (`success`, `accent`, `warning`, `dim`, `muted`) map to which UI roles, with examples.
- **[TOOLS.md](TOOLS.md)** — Function signatures for the built-in tools available inside extensions (`read`, `bash`, `edit`, `write`).
---
## Hooks & Events
Side-by-side comparison of lifecycle hooks in [Claude Code](https://docs.anthropic.com/en/docs/claude-code/hooks) vs [Pi Agent](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md#events).
| Category | Claude Code | Pi Agent | Available In |
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ |
| **Session** | `SessionStart`, `SessionEnd` | `session_start`, `session_shutdown` | Both |
| **Input** | `UserPromptSubmit` | `input` | Both |
| **Tool** | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | `tool_call`, `tool_result`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | Both |
| **Bash** | — | `BashSpawnHook`, `user_bash` | Pi |
| **Permission** | `PermissionRequest` | — | CC |
| **Compact** | `PreCompact` | `session_before_compact`, `session_compact` | Both |
| **Branching** | — | `session_before_fork`, `session_fork`, `session_before_switch`, `session_switch`, `session_before_tree`, `session_tree` | Pi |
| **Agent / Turn** | — | `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end` | Pi |
| **Message** | — | `message_start`, `message_update`, `message_end` | Pi |
| **Model / Context** | — | `model_select`, `context` | Pi |
| **Sub-agents** | `SubagentStart`, `SubagentStop`, `TeammateIdle`, `TaskCompleted` | — | CC |
| **Config** | `ConfigChange` | — | CC |
| **Worktree** | `WorktreeCreate`, `WorktreeRemove` | — | CC |
| **System** | `Stop`, `Notification` | — | CC |
## Resources
## Pi Documentation
| Doc | Description |
| ------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [Mario's Twitter](https://x.com/badlogicgames) | Creator of Pi Coding Agent |
| [README.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md) | Overview and getting started |
| [sdk.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md) | TypeScript SDK reference |
| [rpc.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md) | RPC protocol specification |
| [json.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/json.md) | JSON event stream format |
| [providers.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) | API keys and provider setup |
| [models.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md) | Custom models (Ollama, vLLM, etc.) |
| [extensions.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md) | Extension system |
| [skills.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md) | Skills (Agent Skills standard) |
| [settings.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md) | Configuration |
| [compaction.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md) | Context compaction |
## Master Agentic Coding
> Prepare for the future of software engineering
Learn tactical agentic coding patterns with [Tactical Agentic Coding](https://agenticengineer.com/tactical-agentic-coding?y=pivscc)
Follow the [IndyDevDan YouTube channel](https://www.youtube.com/@indydevdan) to improve your agentic coding advantage.
+75
View File
@@ -0,0 +1,75 @@
# Pi Reserved Keybindings
Extensions **cannot** override these shortcuts — they are silently skipped by `registerShortcut()`.
| Key | Action |
|-----|--------|
| `escape` | interrupt |
| `ctrl+c` | clear / copy |
| `ctrl+d` | exit |
| `ctrl+z` | suspend |
| `shift+tab` | cycleThinkingLevel |
| `ctrl+p` | cycleModelForward |
| `ctrl+shift+p` | cycleModelBackward |
| `ctrl+l` | selectModel |
| `ctrl+o` | expandTools |
| `ctrl+t` | toggleThinking |
| `ctrl+g` | externalEditor |
| `alt+enter` | followUp |
| `enter` | submit / selectConfirm |
| `ctrl+k` | deleteToLineEnd |
## Non-Reserved Built-in Keys
Extensions **can** override these (Pi will warn but allow it).
| Key | Action |
|-----|--------|
| `up` / `down` | cursor / select navigation |
| `left` / `right` | cursor movement |
| `ctrl+a` | cursorLineStart |
| `ctrl+b` | cursorLeft |
| `ctrl+e` | cursorLineEnd |
| `ctrl+f` | cursorRight |
| `ctrl+n` | toggleSessionNamedFilter |
| `ctrl+r` | renameSession |
| `ctrl+s` | toggleSessionSort |
| `ctrl+u` | deleteToLineStart |
| `ctrl+v` | pasteImage |
| `ctrl+w` | deleteWordBackward |
| `ctrl+y` | yank |
| `ctrl+]` | jumpForward |
| `ctrl+-` | undo |
| `ctrl+alt+]` | jumpBackward |
| `alt+b` | cursorWordLeft |
| `alt+d` | deleteWordForward |
| `alt+f` | cursorWordRight |
| `alt+y` | yankPop |
| `alt+up` | dequeue |
| `alt+backspace` | deleteWordBackward |
| `alt+delete` | deleteWordForward |
| `alt+left` / `alt+right` | cursorWord left/right |
| `ctrl+left` / `ctrl+right` | cursorWord left/right |
| `shift+enter` | newLine |
| `home` / `end` | cursorLineStart/End |
| `pageUp` / `pageDown` | page navigation |
| `backspace` | deleteCharBackward |
| `delete` | deleteCharForward |
| `tab` | tab |
## Safe Keys for Extensions
These `ctrl+letter` combos are **free** and work in all terminals:
| Key | Notes |
|-----|-------|
| `ctrl+x` | Safe |
| `ctrl+q` | May be intercepted by terminal (XON/XOFF flow control) |
| `ctrl+h` | Alias for backspace in some terminals — use with caution |
## macOS Notes
- `alt+letter` combos type special characters in most macOS terminals — they don't send alt sequences
- `ctrl+shift+letter` requires Kitty keyboard protocol (Kitty, Ghostty, WezTerm)
- `ctrl+alt+letter` works in legacy terminals but may conflict with macOS system shortcuts
- **Safest bet on macOS:** stick to `ctrl+letter` combos from the free list above, or use `f1``f12`
+29
View File
@@ -0,0 +1,29 @@
# Theme Color Conventions
Extensions in this repo use a consistent color language mapped to Pi's theme tokens. Follow these rules when building new extensions.
## Color Roles
| Token | Role | Used For |
|-----------|-------------------|-----------------------------------------------|
| `success` | Primary value | Token counts, hash fills, branch name, counts |
| `accent` | Secondary value | Percentages, tool names, token out counts |
| `warning` | Punctuation/frame | Brackets `[]`, parens `()`, pipes `|`, cost |
| `dim` | Filler/spacing | Dashes, labels ("in", "out"), separators |
| `muted` | Subdued text | CWD name, fallback states |
## Examples
```
Context meter: warning([) success(###) dim(---) warning(]) accent(30%)
Git branch: dim(pi-vs-cc) warning(() success(main) warning())
Token stats: success(1.2k) dim(in) accent(340) dim(out) warning($0.0042)
Tool tally: accent(Bash) success(3) warning(|) accent(Read) success(7)
```
## Rationale
- **Green (success)** draws the eye to live values that change — counts, filled bars, branch
- **Cyan (accent)** highlights identifiers and secondary metrics — names, percentages
- **Yellow (warning)** frames structure — delimiters tell you where one value ends and the next begins
- **Dim** recedes into the background — labels and filler shouldn't compete for attention
+27
View File
@@ -0,0 +1,27 @@
```ts
// Read the contents of a file. Supports text files and images. Output is truncated to 2000 lines or 50KB.
function read(
path: string, // Path to the file to read (relative or absolute)
limit?: number, // Maximum number of lines to read
offset?: number // Line number to start reading from (1-indexed)
): string;
// Execute a bash command in the current working directory. Returns stdout and stderr.
function bash(
command: string, // Bash command to execute
timeout?: number // Timeout in seconds (optional, no default timeout)
): string;
// Edit a file by replacing exact text. The oldText must match exactly (including whitespace).
function edit(
path: string, // Path to the file to edit (relative or absolute)
oldText: string, // Exact text to find and replace (must match exactly)
newText: string // New text to replace the old text with
): void;
// Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.
function write(
path: string, // Path to the file to write (relative or absolute)
content: string // Content to write to the file
): void;
```
+334
View File
@@ -0,0 +1,334 @@
# Audit Report: give.charityright.org.uk
**Date:** 9 March 2026
**Auditor:** Claude Code (automated Playwright audit)
**Viewport tested:** 1440×900 (desktop), 375×812 (mobile)
**Entry URL:** https://give.charityright.org.uk → auto-redirects to `/sadaqah`
---
## Overview
`give.charityright.org.uk` is the donation micro-site for Charity Right (UK Charity #1155108). It is a **single-page, campaign-focused donation portal** currently showing a **Sadaqah fundraising page**. The site allows visitors to:
- Select a donation amount (slider + quick-pick buttons, £3–£15 default range)
- Optionally add UK Gift Aid (with house number + postcode collection)
- Complete payment via Stripe (card + Google Pay)
- Share the campaign
**Tech stack clues:**
- Custom server-side rendered (SSR) app — no Vue/React/Next fingerprint detected at root
- Stripe.js v3 for payments
- Cloudflare (CDN, challenge platform, Turnstile/hCaptcha)
- Google Tag Manager (GTM-PX5XJXX)
- TikTok Pixel, Facebook Pixel, Google Analytics (multiple GA4 properties), Hotjar 3395285
- CookieHub for consent management
- WAHA (WhatsApp HTTP API) running at `waha.charityright.org.uk` — separate service, credentials leaked in console (see Security)
---
## Phase 1 — Homepage Audit
### Redirect behaviour
- `/``/sadaqah` (permanent or temporary redirect — not a true homepage, no navigation)
### Page title
-`Give Sadaqah — Charity Right` — clear, descriptive
### Meta tags
| Tag | Value | Issue |
|-----|-------|-------|
| `<meta name="description">` | **MISSING** | ❌ No standard description tag |
| `<meta property="og:title">` | `Give Sadaqah — Charity Right` | ✅ |
| `<meta property="og:description">` | `50p feeds a child. Give Sadaqah in 30 seconds.` | ✅ |
| `<meta property="og:image">` | `https://www.charityright.org.uk/wp-content/uploads/2026/02/cr-wrong-1.jpg` | ⚠️ Cross-domain OG image (WordPress) |
| `<meta name="robots">` | **MISSING** | ⚠️ No robots directives |
| `<meta name="viewport">` | `width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover` | ⚠️ `user-scalable=no` disables pinch-zoom — accessibility violation (WCAG 1.4.4) |
### Console errors on load
- ✅ 0 JS errors on sadaqah page itself
- 1 WARNING: Hotjar refuses to load (detects headless browser user-agent) — benign in production
### Cookie consent
- ✅ CookieHub banner shown on first visit (`dialog "About cookies on this site"`)
- ✅ Three options: "Allow all cookies", "Deny all", "Cookie settings"
- ⚠️ "Cookie settings" link uses `href="#"` (dead anchor) — should be `href` to settings panel or use `button` semantics
---
## Phase 2 — Navigation & Links
The site has **no navigation bar or header menu**. It is a single campaign page only. There are no internal links to other sections of the give site.
**External link found:**
- `https://www.charityright.org.uk/donate/` — links to main website (different domain)
**Routes tested:**
| Path | Result |
|------|--------|
| `/sadaqah` | ✅ 200 — Sadaqah donation page |
| `/zakat` | ❌ 404 — "Not Found" blank page |
| `/fidya` | ❌ 404 — "Not Found" blank page |
| `/qurban` | ❌ 404 — "Not Found" blank page |
| `/kaffarah` | ❌ 404 — "Not Found" blank page |
| `/login` | ❌ 404 — "Not Found" blank page |
| `/register` | ❌ 404 — "Not Found" blank page |
| `/campaigns` | ❌ 404 — "Not Found" blank page |
| `/donate` | ❌ 404 — "Not Found" blank page |
| `/appeal` | ❌ 404 — "Not Found" blank page |
**Key issue:** The 404 page returns **no `<title>`** tag and **no styled error page** — just the raw text "Not Found". There is no redirect to a helpful error page and no way back to a valid URL.
---
## Phase 3 — Donation Flow
### Flow architecture
The site uses an **inline slide-up payment panel** (overlay `div#overlay`) rather than a separate checkout page. This is a smooth UX pattern.
### Step 1: Amount selection
- Slider control: 6 (£6 default, feeds 12 children @ 50p/meal)
- Quick-pick buttons: £3, £6, £9, £12, £15
- ✅ Real-time meal counter updates
- ✅ Clear impact messaging ("Feeds 12 children")
### Step 2: Gift Aid
- ✅ Gift Aid toggle button (pre-selected/enabled by default)
- ✅ Calculates HMRC top-up (£6 → £7.50 total)
- House number + postcode fields appear when Gift Aid active
- ⚠️ **No validation visible** if Gift Aid address fields are left empty when proceeding
- ⚠️ Gift Aid declaration text is small/light — readability concern
### Step 3: "Give £6 Sadaqah" button
- Clicking opens a side/bottom panel (`div#overlay.open`)
- The overlay **blocks interaction** with the main page while open (correct modal behaviour)
### Step 4: Checkout panel ("Complete your Sadaqah")
Fields present:
| Field | Type | Placeholder | Label visible |
|-------|------|-------------|---------------|
| Full name | `textbox` | "As it appears on your card" | ✅ "Full name" |
| Email | `textbox` | "For your donation receipt" | ✅ "Email" |
| Card number | Stripe iframe | "Card number" | ✅ "Number" |
| Expiry | Stripe iframe | "MM / YY" | ✅ |
| CVC | Stripe iframe | "CVC" | ✅ |
- "Donate £6" button is **`disabled` by default** — only enabled when Stripe iframe reports complete card details
- ✅ Correct — prevents empty form submission
- ⚠️ Clicking "Give £6 Sadaqah" without filling name/email still opens the overlay — those fields have **no pre-validation** before opening the panel
- ✅ No `<form>` element (0 forms found) — form data handled entirely via JS/Stripe Elements
- ✅ Google Pay option available in Stripe Elements
- ✅ Trust signals: "🔒 Encrypted · Powered by Stripe" and "256-bit secure · No account needed · UK Charity 1155108"
### Empty form submission
- Donate button stays disabled until Stripe card fields are valid — no empty submit possible via UI
- **No visible validation errors** for empty name/email fields — they appear to be validated client-side by Stripe before enabling the button (TBC)
### Donation amount £1 test
- Minimum selectable via slider unclear (minimum appears to be £3 via preset buttons)
- Manual slider minimum not tested — slider defaults to £6
---
## Phase 4 — Mobile Responsiveness (375×812)
### Layout
- ✅ Page renders correctly at 375×812
- ✅ Same UI elements visible — no hidden/broken sections
- ✅ Campaign image scales correctly
- ✅ Quick-pick amount buttons visible and tappable
- ✅ Gift Aid toggle renders correctly
- ✅ Checkout panel (overlay) renders at mobile size
- ⚠️ `user-scalable=no` in viewport meta means users **cannot zoom** on mobile — this is an accessibility violation for low-vision users
- ⚠️ No hamburger menu (site is single-page, so this is acceptable, but there's no navigation at all)
- ✅ No horizontal scroll detected at 375px width
---
## Phase 5 — Performance & Accessibility
### Accessibility
| Check | Result | Issue |
|-------|--------|-------|
| Images missing `alt` | 0 | ✅ |
| Dead anchors `href="#"` | 1 | ⚠️ Cookie settings button uses `href="#"` |
| Unlabelled inputs | 1 | ⚠️ One input has no `aria-label` or `placeholder` |
| `user-scalable=no` in viewport | YES | ❌ WCAG 1.4.4 violation |
| Inline `onclick` handlers | 3 | ⚠️ 3 elements use inline event handlers |
| No `<nav>` landmark | Confirmed | ⚠️ No navigation landmark (single-page is valid but no skip-to-content) |
| No `<meta name="description">` | Confirmed | ❌ SEO/accessibility issue |
### Performance
- **Page load time:** 2,433ms (performance.timing) — acceptable
- **External scripts loaded:** 9 external `<script src>` tags
**External scripts inventory:**
1. `analytics.tiktok.com` — TikTok Pixel (×3 scripts)
2. `connect.facebook.net` — Facebook Pixel (×2 scripts, including large config JS)
3. `static.hotjar.com` — Hotjar session recording
4. `www.googletagmanager.com` — GTM (×1) + GA4 gtag (×4 separate GA properties!)
5. `cdn.cookiehub.eu` — CookieHub consent
6. `js.stripe.com/v3/` — Stripe.js
7. `googleads.g.doubleclick.net` — Google Ads conversion tracking (×2)
**⚠️ Performance concern:** 4 separate Google Analytics 4 properties (`G-1B4QR9YTTB`, `G-6RGJ9BTW2Q`, `G-4DB6TJNHZR`, `AW-876060108`, `AW-925998688`) + GTM + TikTok + Facebook + Hotjar = **significant tracking script bloat**. Each fires multiple analytics events per page load.
**Network errors (analytics):** Multiple `net::ERR_ABORTED` for analytics.google.com POSTs on page navigation — these appear to be in-flight beacons aborted during navigation (non-critical but noisy).
---
## Phase 6 — Key Pages
| Page | Status | Notes |
|------|--------|-------|
| `/sadaqah` | ✅ 200 | Main and only working page |
| `/zakat` | ❌ 404 | Common Islamic giving category — missing |
| `/fidya` | ❌ 404 | Ramadan-specific — missing |
| `/qurban` | ❌ 404 | Eid-related — missing |
| `/kaffarah` | ❌ 404 | Missing |
| `/login` | ❌ 404 | No user accounts on this domain |
| `/register` | ❌ 404 | No user accounts on this domain |
| `/campaigns` | ❌ 404 | Missing |
The site appears to only have the one `/sadaqah` route currently live. All other paths return bare 404 responses.
---
## Phase 7 — Security
### HTTPS
- ✅ Site is fully HTTPS (`https://give.charityright.org.uk`)
- ✅ Stripe iframe loads over HTTPS
- ✅ No mixed content detected
- ✅ Cloudflare CDN with challenge platform (`cdn-cgi/challenge-platform`)
### Forms
- ✅ No `<form>` elements found — payment handled entirely via Stripe.js (no raw card data hits the server)
- ✅ Stripe tokenisation means card details never touch CharityRight servers
### Cookies
| Cookie | Domain | Secure? | HttpOnly? | Concern |
|--------|--------|---------|-----------|---------|
| `cf_clearance` | `.charityright.org.uk` | ✅ (HTTPS-only session) | N/A | Cloudflare bot protection |
| `__stripe_mid`, `__stripe_sid` | `.give.charityright.org.uk` | ✅ | Unknown | Stripe session — should be HttpOnly |
| `cookiehub` | `.charityright.org.uk` | ✅ | N/A | Consent preferences |
| `_ga`, `_ga_*` | `.charityright.org.uk` | ✅ | N/A | Google Analytics |
| `_fbp` | `.charityright.org.uk` | ✅ | N/A | Facebook Pixel |
| `_ttp` | `.charityright.org.uk` + `.tiktok.com` | ✅ | N/A | TikTok tracking |
| `IDE` | `.doubleclick.net` | ✅ | N/A | Google Ads |
⚠️ `__stripe_mid` and `__stripe_sid` are visible in `document.cookie` — meaning they are **not HttpOnly**. Stripe session cookies should ideally be HttpOnly to prevent XSS token theft (though Stripe sets these itself — CharityRight cannot control this).
### 🚨 CRITICAL: Credentials in Browser Console
**From previous browsing session logs captured in the test environment:**
```
[ERROR] SecurityError: Failed to execute 'replaceState' on 'History':
A history state object with URL
'https://waha.charityright.org.uk/dashboard/' cannot be created in a document
with origin 'https://waha.charityright.org.uk' and URL
'https://admin:J59vAcWI56BReJEFCi@waha.charityright.org.uk/dashboard/'.
```
**This is a CRITICAL security issue.** The URL `https://admin:J59vAcWI56BReJEFCi@waha.charityright.org.uk/dashboard/` contains **basic authentication credentials in plaintext** (`admin:J59vAcWI56BReJEFCi`). These were captured in the browser's console log, meaning:
1. Anyone with DevTools access to `waha.charityright.org.uk` could see these credentials
2. The credentials are for the WAHA (WhatsApp HTTP API) admin dashboard
3. WAHA controls WhatsApp messaging for the organisation — a breach here could expose donor communications
**Immediate action required:** Change the WAHA admin password and remove credentials from any hardcoded URLs. Use proper session-based auth instead.
### Hub API 401 Errors
Multiple failed requests to `hub.quikcue.com/api/user/*` (401 Unauthorized):
```
GET https://hub.quikcue.com/api/user/lifecycle → 401
GET https://hub.quikcue.com/api/user/admin → 401
GET https://hub.quikcue.com/api/user/init → 401
```
The give.charityright.org.uk site is calling **QuikCue's hub API** on every page load and receiving 401s. These appear to be unauthenticated calls. This could indicate a misconfigured integration or leftover code from a shared codebase.
### Development URL in Production
From console errors: `http://localhost:3000/api/pledges → 400 Bad Request`
A hardcoded `localhost:3000` URL is being called from production. This is a development/staging configuration leak.
---
## Console Errors Summary
| Error | Source | Severity |
|-------|--------|----------|
| `localhost:3000/api/pledges` returning 400 | Sadaqah page JS | 🔴 High — dev URL in prod |
| `hub.quikcue.com/api/user/*` returning 401 (×8+ times) | Page load | 🟠 Medium — unnecessary failed requests |
| `waha.charityright.org.uk` credentials in URL | WAHA dashboard JS | 🔴 Critical — credential leak |
| `waha.charityright.org.uk/api/*` returning 401 | WAHA dashboard | 🟡 Low — expected if not logged in |
| `give.charityright.org.uk/favicon.ico` returning 404 | Browser | 🟡 Low — missing favicon |
| `analytics.google.com` ERR_ABORTED (×6) | Navigation beacons | 🟢 Info — benign race condition |
---
## Summary Table
| # | Issue | Location | Severity | Category |
|---|-------|----------|----------|----------|
| 1 | **Credentials in URL**: `admin:J59vAcWI56BReJEFCi` in WAHA console error | `waha.charityright.org.uk` | 🔴 **Critical** | Security |
| 2 | **`localhost:3000/api/pledges`** called from production | `/sadaqah` JS | 🔴 **Critical** | Config / Security |
| 3 | **All routes 404** except `/sadaqah` — no error page, no redirect | `/zakat`, `/fidya`, etc. | 🟠 **High** | Navigation |
| 4 | **404 page has no title, no styling, no back link** | All 404 responses | 🟠 **High** | UX |
| 5 | **`hub.quikcue.com` API called 8+ times with 401** on every page load | `/sadaqah` | 🟠 **High** | Performance / Config |
| 6 | **Missing `<meta name="description">`** | All pages | 🟠 **High** | SEO |
| 7 | **`user-scalable=no` in viewport meta** — disables pinch zoom | All pages | 🟠 **High** | Accessibility (WCAG 1.4.4) |
| 8 | **4 separate GA4 properties** + 2 Google Ads + TikTok + Facebook + Hotjar | All pages | 🟡 **Medium** | Performance / Privacy |
| 9 | **No Gift Aid address validation** before proceeding to checkout | `/sadaqah` | 🟡 **Medium** | UX / Data quality |
| 10 | **Name/email not validated** before overlay opens | `/sadaqah` checkout | 🟡 **Medium** | UX |
| 11 | **OG image served from WordPress domain** (`charityright.org.uk/wp-content/`) | `/sadaqah` | 🟡 **Medium** | Reliability / Performance |
| 12 | **No `<meta name="robots">`** directive | All pages | 🟡 **Medium** | SEO |
| 13 | **`href="#"`** on cookie settings anchor | Cookie banner | 🟡 **Medium** | Accessibility |
| 14 | **1 unlabelled input** (no aria-label, no placeholder) | `/sadaqah` | 🟡 **Medium** | Accessibility |
| 15 | **3 inline `onclick` handlers** | `/sadaqah` | 🟡 **Medium** | Code quality |
| 16 | **Hotjar not loading** (detects headless/bot UA) | All pages | 🟡 **Medium** | Analytics |
| 17 | **Missing favicon** (404 on `/favicon.ico`) | All pages | 🟢 **Low** | Branding |
| 18 | **No skip-to-content link** | All pages | 🟢 **Low** | Accessibility |
| 19 | **No `<nav>` landmark** | All pages | 🟢 **Low** | Accessibility |
| 20 | **Stripe session cookies not HttpOnly** | `/sadaqah` | 🟢 **Low** | Security (Stripe-controlled) |
---
## Recommendations (Priority Order)
### 🔴 Immediate (Critical)
1. **Rotate WAHA admin credentials**`admin:J59vAcWI56BReJEFCi` is exposed in browser console. Change password immediately. Audit who accessed WAHA.
2. **Remove `localhost:3000` URL** from production build — likely a `.env` misconfiguration. The `/api/pledges` call should point to the production API endpoint.
### 🟠 High Priority
3. **Add styled 404 page** with charity branding, navigation back to `/sadaqah`, and proper `<title>`.
4. **Fix `hub.quikcue.com` API calls** — either authenticate them properly or remove them from the give site (wrong codebase/integration).
5. **Add `<meta name="description">`** — currently only `og:description` is set.
6. **Remove `user-scalable=no`** from viewport meta — replace with `user-scalable=yes` to comply with WCAG 1.4.4.
### 🟡 Medium Priority
7. **Audit tracking scripts** — consolidate 4 GA4 properties if possible, remove duplicates. Each extra script adds load time and GDPR surface area.
8. **Validate Gift Aid fields before opening checkout overlay** — show inline error if Gift Aid is enabled but address fields are empty.
9. **Validate name/email before showing checkout overlay** — currently you can open the payment panel without entering name/email.
10. **Move OG image to `give.charityright.org.uk` CDN** — remove cross-domain WordPress dependency.
11. **Replace `href="#"` on Cookie Settings** with proper button element or `href="javascript:void(0)"`.
12. **Fix unlabelled input** — audit the 1 input missing both `aria-label` and `placeholder`.
### 🟢 Low Priority
13. Add favicon to `give.charityright.org.uk`.
14. Add `<meta name="robots" content="index,follow">` explicitly.
15. Add skip-to-content link for keyboard/screen reader users.
---
## Screenshots Captured
| File | Description |
|------|-------------|
| `screenshots/cr-homepage.png` | Full page — sadaqah donation page at 1440×900 |
| `screenshots/cr-donation-form.png` | Full page after clicking "Give £6 Sadaqah" |
| `screenshots/cr-form-filled.png` | Checkout overlay with name/email filled |
| `screenshots/cr-form-ready.png` | Checkout overlay with full card details entered, Donate button enabled |
| `screenshots/cr-overlay-open.png` | Overlay state after form interaction |
| `screenshots/cr-mobile.png` | Full page at 375×812 mobile viewport |
| `screenshots/cr-zakat.png` | 404 page for /zakat |
+8
View File
@@ -0,0 +1,8 @@
AYN_MALWAREBAZAAR_API_KEY=
AYN_VIRUSTOTAL_API_KEY=
AYN_SCAN_PATH=/
AYN_QUARANTINE_PATH=/var/lib/ayn-antivirus/quarantine
AYN_DB_PATH=/var/lib/ayn-antivirus/signatures.db
AYN_LOG_PATH=/var/log/ayn-antivirus/
AYN_AUTO_QUARANTINE=false
AYN_SCAN_SCHEDULE=0 2 * * *
+11
View File
@@ -0,0 +1,11 @@
__pycache__
*.pyc
.env
*.db
dist/
build/
*.egg-info
ayn_antivirus/signatures/yara_rules/*.yar
/quarantine_vault/
.pytest_cache
.coverage
+25
View File
@@ -0,0 +1,25 @@
.PHONY: install dev-install test lint scan update-sigs clean
install:
pip install .
dev-install:
pip install -e ".[dev]"
test:
pytest --cov=ayn_antivirus tests/
lint:
ruff check ayn_antivirus/
black --check ayn_antivirus/
scan:
ayn-antivirus scan
update-sigs:
ayn-antivirus update
clean:
rm -rf build/ dist/ *.egg-info .pytest_cache .coverage
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name '*.pyc' -delete
+574
View File
@@ -0,0 +1,574 @@
<p align="center">
<pre>
██████╗ ██╗ ██╗███╗ ██╗
██╔══██╗╚██╗ ██╔╝████╗ ██║
███████║ ╚████╔╝ ██╔██╗ ██║
██╔══██║ ╚██╔╝ ██║╚██╗██║
██║ ██║ ██║ ██║ ╚████║
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝
⚔️ AYN ANTIVIRUS v1.0.0 ⚔️
Server Protection Suite
</pre>
</p>
<p align="center">
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.9%2B-blue?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.9+"></a>
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="#"><img src="https://img.shields.io/badge/platform-linux-lightgrey?style=for-the-badge&logo=linux&logoColor=white" alt="Platform: Linux"></a>
<a href="#"><img src="https://img.shields.io/badge/version-1.0.0-orange?style=for-the-badge" alt="Version 1.0.0"></a>
</p>
---
# AYN Antivirus
**Comprehensive anti-virus, anti-malware, anti-spyware, and anti-cryptominer protection for Linux servers.**
AYN Antivirus is a purpose-built security suite designed for server environments. It combines signature-based detection, YARA rules, heuristic analysis, and live system inspection to catch threats that traditional AV tools miss — from cryptominers draining your CPU to rootkits hiding in kernel modules.
---
## Features
- 🛡️ **Real-time file system monitoring** — watches directories with inotify/FSEvents via watchdog, scans new and modified files instantly
- 🔍 **Deep file scanning with multiple detection engines** — parallel, multi-threaded scans across signature, YARA, and heuristic detectors
- 🧬 **YARA rule support** — load custom and community YARA rules for flexible pattern matching
- 📊 **Heuristic analysis** — Shannon entropy scoring, obfuscation detection, reverse-shell patterns, permission anomalies
- ⛏️ **Cryptominer detection** — process-level, network-level, and file-content analysis (stratum URLs, wallet addresses, pool domains)
- 🕵️ **Spyware & keylogger detection** — identifies keyloggers, screen/audio capture tools, data exfiltration, and shell-profile backdoors
- 🦠 **Rootkit detection** — hidden processes, hidden kernel modules, LD_PRELOAD hijacking, tampered logs, hidden network ports
- 🌐 **Auto-updating threat signatures** — pulls from abuse.ch feeds (MalwareBazaar, ThreatFox, URLhaus, Feodo Tracker) and Emerging Threats
- 🔒 **Encrypted quarantine vault** — isolates malicious files with Fernet (AES-128-CBC + HMAC-SHA256) encryption and JSON metadata
- 🔧 **Auto-remediation & patching** — kills rogue processes, fixes permissions, blocks IPs/domains, cleans cron jobs, restores system binaries
- 📝 **Reports in Text, JSON, HTML** — generate human-readable or machine-parseable reports from scan results
-**Scheduled scanning** — built-in cron-style scheduler for unattended operation
---
## Quick Start
```bash
# Install
pip install .
# Update threat signatures
sudo ayn-antivirus update
# Run a full scan
sudo ayn-antivirus scan
# Quick scan (high-risk dirs only)
sudo ayn-antivirus scan --quick
# Check protection status
ayn-antivirus status
```
---
## Installation
### From pip (local)
```bash
pip install .
```
### Editable install (development)
```bash
pip install -e ".[dev]"
```
### From source with Make
```bash
make install # production
make dev-install # development (includes pytest, black, ruff)
```
### System dependencies
AYN uses [yara-python](https://github.com/VirusTotal/yara-python) for rule-based detection. On most systems pip handles this automatically, but you may need the YARA C library:
| Distro | Command |
|---|---|
| **Debian / Ubuntu** | `sudo apt install yara libyara-dev` |
| **RHEL / CentOS / Fedora** | `sudo dnf install yara yara-devel` |
| **Arch** | `sudo pacman -S yara` |
| **macOS (Homebrew)** | `brew install yara` |
After the system library is installed, `pip install yara-python` (or `pip install .`) will link against it.
---
## Usage
All commands accept `--verbose` / `-v` for detailed output and `--config <path>` to load a custom YAML config file.
### File System Scanning
```bash
# Full scan — all configured paths
sudo ayn-antivirus scan
# Quick scan — /tmp, /var/tmp, /dev/shm, crontabs
sudo ayn-antivirus scan --quick
# Deep scan — includes memory and hidden artifacts
sudo ayn-antivirus scan --deep
# Scan a single file
ayn-antivirus scan --file /tmp/suspicious.bin
# Targeted path with exclusions
sudo ayn-antivirus scan --path /home --exclude '*.log' --exclude '*.gz'
```
### Process Scanning
```bash
# Scan running processes for miners & suspicious CPU usage
sudo ayn-antivirus scan-processes
```
Checks every running process against known miner names (xmrig, minerd, ethminer, etc.) and flags anything above the CPU threshold (default 80%).
### Network Scanning
```bash
# Inspect active connections for mining pool traffic
sudo ayn-antivirus scan-network
```
Compares remote addresses against known mining pool domains and suspicious ports (3333, 4444, 5555, 14444, etc.).
### Update Signatures
```bash
# Fetch latest threat intelligence from all feeds
sudo ayn-antivirus update
# Force re-download even if signatures are fresh
sudo ayn-antivirus update --force
```
### Quarantine Management
```bash
# List quarantined items
ayn-antivirus quarantine list
# View details of a quarantined item
ayn-antivirus quarantine info 1
# Restore a quarantined file to its original location
sudo ayn-antivirus quarantine restore 1
# Permanently delete a quarantined item
ayn-antivirus quarantine delete 1
```
### Real-Time Monitoring
```bash
# Watch configured paths in the foreground (Ctrl+C to stop)
sudo ayn-antivirus monitor
# Watch specific paths
sudo ayn-antivirus monitor --paths /var/www --paths /tmp
# Run as a background daemon
sudo ayn-antivirus monitor --daemon
```
### Report Generation
```bash
# Plain text report to stdout
ayn-antivirus report
# JSON report to a file
ayn-antivirus report --format json --output /tmp/report.json
# HTML report
ayn-antivirus report --format html --output report.html
```
### Auto-Fix / Remediation
```bash
# Preview all remediation actions (no changes)
sudo ayn-antivirus fix --all --dry-run
# Apply all remediations
sudo ayn-antivirus fix --all
# Fix a specific threat by ID
sudo ayn-antivirus fix --threat-id 3
```
### Status Check
```bash
# View protection status at a glance
ayn-antivirus status
```
Displays signature freshness, last scan time, quarantine count, real-time monitor state, and engine toggles.
### Configuration
```bash
# Show active configuration
ayn-antivirus config
# Set a config value (persisted to ~/.ayn-antivirus/config.yaml)
ayn-antivirus config --set auto_quarantine true
ayn-antivirus config --set scan_schedule '0 3 * * *'
```
---
## Configuration
### Config file locations
AYN loads configuration from the first file found (in order):
| Priority | Path |
|---|---|
| 1 | Explicit `--config <path>` flag |
| 2 | `/etc/ayn-antivirus/config.yaml` |
| 3 | `~/.ayn-antivirus/config.yaml` |
### Config file options
```yaml
# Directories to scan
scan_paths:
- /
exclude_paths:
- /proc
- /sys
- /dev
- /run
- /snap
# Storage
quarantine_path: /var/lib/ayn-antivirus/quarantine
db_path: /var/lib/ayn-antivirus/signatures.db
log_path: /var/log/ayn-antivirus/
# Behavior
auto_quarantine: false
scan_schedule: "0 2 * * *"
max_file_size: 104857600 # 100 MB
# Engines
enable_yara: true
enable_heuristics: true
enable_realtime_monitor: false
# API keys (optional)
api_keys:
malwarebazaar: ""
virustotal: ""
```
### Environment variables
Environment variables override config file values. Copy `.env.sample` to `.env` and populate as needed.
| Variable | Description | Default |
|---|---|---|
| `AYN_SCAN_PATH` | Comma-separated scan paths | `/` |
| `AYN_QUARANTINE_PATH` | Quarantine vault directory | `/var/lib/ayn-antivirus/quarantine` |
| `AYN_DB_PATH` | Signature database path | `/var/lib/ayn-antivirus/signatures.db` |
| `AYN_LOG_PATH` | Log directory | `/var/log/ayn-antivirus/` |
| `AYN_AUTO_QUARANTINE` | Auto-quarantine on detection (`true`/`false`) | `false` |
| `AYN_SCAN_SCHEDULE` | Cron expression for scheduled scans | `0 2 * * *` |
| `AYN_MAX_FILE_SIZE` | Max file size to scan (bytes) | `104857600` |
| `AYN_MALWAREBAZAAR_API_KEY` | MalwareBazaar API key | — |
| `AYN_VIRUSTOTAL_API_KEY` | VirusTotal API key | — |
---
## Threat Intelligence Feeds
AYN aggregates indicators from multiple open-source threat intelligence feeds:
| Feed | Source | Data Type |
|---|---|---|
| **MalwareBazaar** | [bazaar.abuse.ch](https://bazaar.abuse.ch) | Malware sample hashes (SHA-256) |
| **ThreatFox** | [threatfox.abuse.ch](https://threatfox.abuse.ch) | IOCs — IPs, domains, URLs |
| **URLhaus** | [urlhaus.abuse.ch](https://urlhaus.abuse.ch) | Malware distribution URLs |
| **Feodo Tracker** | [feodotracker.abuse.ch](https://feodotracker.abuse.ch) | Botnet C2 IP addresses |
| **Emerging Threats** | [rules.emergingthreats.net](https://rules.emergingthreats.net) | Suricata / Snort IOCs |
| **YARA Rules** | Community & custom | Pattern-matching rules (`signatures/yara_rules/`) |
Signatures are stored in a local SQLite database (`signatures.db`) with separate tables for hashes, IPs, domains, and URLs. Run `ayn-antivirus update` to pull the latest data.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ CLI (cli.py) │
│ Click commands + Rich UI │
└───────────────────────────────┬─────────────────────────────────┘
┌───────────▼───────────┐
│ Core Scan Engine │
│ (core/engine.py) │
└───┬────┬────┬────┬───┘
│ │ │ │
┌─────────────┘ │ │ └─────────────┐
▼ ▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Detectors │ │ Scanners │ │ Monitor │
│ ┌─────────────┐ │ │ ┌──────────┐ │ │ ┌──────────────────┐ │
│ │ Signature │ │ │ │ File │ │ │ │ Real-time │ │
│ │ YARA │ │ │ │ Process │ │ │ │ (watchdog) │ │
│ │ Heuristic │ │ │ │ Network │ │ │ └──────────────────┘ │
│ │ Cryptominer │ │ │ │ Memory │ │ └──────────────────────┘
│ │ Spyware │ │ │ └──────────┘ │
│ │ Rootkit │ │ └──────────────┘
│ └─────────────┘ │
└─────────────────┘
│ ┌──────────────────────┐
│ ┌───────────────────┐ │ Signatures │
└───►│ Event Bus │ │ ┌──────────────────┐ │
│ (core/event_bus) │ │ │ Feed Manager │ │
└──────┬────────────┘ │ │ Hash DB │ │
│ │ │ IOC DB │ │
┌──────────┼──────────┐ │ │ YARA Rules │ │
▼ ▼ ▼ │ └──────────────────┘ │
┌────────────┐ ┌────────┐ ┌───────┐ └──────────────────────┘
│ Quarantine │ │Reports │ │Remedy │
│ Vault │ │ Gen. │ │Patcher│
│ (Fernet) │ │txt/json│ │ │
│ │ │ /html │ │ │
└────────────┘ └────────┘ └───────┘
```
### Module summary
| Module | Path | Responsibility |
|---|---|---|
| **CLI** | `cli.py` | User-facing commands (Click + Rich) |
| **Config** | `config.py` | YAML & env-var configuration loader |
| **Engine** | `core/engine.py` | Orchestrates file/process/network scans |
| **Event Bus** | `core/event_bus.py` | Internal pub/sub for scan events |
| **Scheduler** | `core/scheduler.py` | Cron-based scheduled scans |
| **Detectors** | `detectors/` | Pluggable detection engines (signature, YARA, heuristic, cryptominer, spyware, rootkit) |
| **Scanners** | `scanners/` | File, process, network, and memory scanners |
| **Monitor** | `monitor/realtime.py` | Watchdog-based real-time file watcher |
| **Quarantine** | `quarantine/vault.py` | Fernet-encrypted file isolation vault |
| **Remediation** | `remediation/patcher.py` | Auto-fix engine (kill, block, clean, restore) |
| **Reports** | `reports/generator.py` | Text, JSON, and HTML report generation |
| **Signatures** | `signatures/` | Feed fetchers, hash DB, IOC DB, YARA rules |
---
## Auto-Patching Capabilities
The remediation engine (`ayn-antivirus fix`) can automatically apply the following fixes:
| Action | Description |
|---|---|
| **Fix permissions** | Strips SUID, SGID, and world-writable bits from compromised files |
| **Kill processes** | Sends SIGKILL to confirmed malicious processes (miners, reverse shells) |
| **Block IPs** | Adds `iptables` DROP rules for C2 and mining pool IP addresses |
| **Block domains** | Redirects malicious domains to `127.0.0.1` via `/etc/hosts` |
| **Clean cron jobs** | Removes entries matching suspicious patterns (curl\|bash, xmrig, etc.) |
| **Fix LD_PRELOAD** | Clears `/etc/ld.so.preload` entries injected by rootkits |
| **Clean SSH keys** | Removes `command=` forced-command entries from `authorized_keys` |
| **Remove startup entries** | Strips malicious lines from init scripts, systemd units, and `rc.local` |
| **Restore binaries** | Reinstalls tampered system binaries via `apt`/`dnf`/`yum` package manager |
> **Tip:** Always run with `--dry-run` first to preview changes before applying.
---
## Running as a Service
Create a systemd unit to run AYN as a persistent real-time monitor:
```ini
# /etc/systemd/system/ayn-antivirus.service
[Unit]
Description=AYN Antivirus Real-Time Monitor
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/ayn-antivirus monitor --daemon
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10
User=root
Group=root
# Hardening
ProtectSystem=strict
ReadWritePaths=/var/lib/ayn-antivirus /var/log/ayn-antivirus
NoNewPrivileges=false
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
```bash
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable ayn-antivirus
sudo systemctl start ayn-antivirus
# Check status
sudo systemctl status ayn-antivirus
# View logs
sudo journalctl -u ayn-antivirus -f
```
Optionally add a timer unit for scheduled signature updates:
```ini
# /etc/systemd/system/ayn-antivirus-update.timer
[Unit]
Description=AYN Antivirus Signature Update Timer
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
```ini
# /etc/systemd/system/ayn-antivirus-update.service
[Unit]
Description=AYN Antivirus Signature Update
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ayn-antivirus update
User=root
```
```bash
sudo systemctl enable --now ayn-antivirus-update.timer
```
---
## Development
### Prerequisites
- Python 3.9+
- [YARA](https://virustotal.github.io/yara/) C library (for yara-python)
### Setup
```bash
git clone <repo-url>
cd ayn-antivirus
pip install -e ".[dev]"
```
### Run tests
```bash
make test
# or directly:
pytest --cov=ayn_antivirus tests/
```
### Lint & format
```bash
make lint
# or directly:
ruff check ayn_antivirus/
black --check ayn_antivirus/
```
### Auto-format
```bash
black ayn_antivirus/
```
### Project layout
```
ayn-antivirus/
├── ayn_antivirus/
│ ├── __init__.py # Package version
│ ├── __main__.py # python -m ayn_antivirus entry point
│ ├── cli.py # Click CLI commands
│ ├── config.py # Configuration loader
│ ├── constants.py # Thresholds, paths, known indicators
│ ├── core/
│ │ ├── engine.py # Scan engine orchestrator
│ │ ├── event_bus.py # Internal event system
│ │ └── scheduler.py # Cron-based scheduler
│ ├── detectors/
│ │ ├── base.py # BaseDetector ABC + DetectionResult
│ │ ├── signature_detector.py
│ │ ├── yara_detector.py
│ │ ├── heuristic_detector.py
│ │ ├── cryptominer_detector.py
│ │ ├── spyware_detector.py
│ │ └── rootkit_detector.py
│ ├── scanners/
│ │ ├── file_scanner.py
│ │ ├── process_scanner.py
│ │ ├── network_scanner.py
│ │ └── memory_scanner.py
│ ├── monitor/
│ │ └── realtime.py # Watchdog-based file watcher
│ ├── quarantine/
│ │ └── vault.py # Fernet-encrypted quarantine
│ ├── remediation/
│ │ └── patcher.py # Auto-fix engine
│ ├── reports/
│ │ └── generator.py # Report output (text/json/html)
│ ├── signatures/
│ │ ├── manager.py # Feed orchestrator
│ │ ├── db/ # Hash DB + IOC DB (SQLite)
│ │ ├── feeds/ # Feed fetchers (abuse.ch, ET, etc.)
│ │ └── yara_rules/ # .yar rule files
│ └── utils/
│ ├── helpers.py
│ └── logger.py
├── tests/ # pytest test suite
├── pyproject.toml # Build config & dependencies
├── Makefile # Dev shortcuts
├── .env.sample # Environment variable template
└── README.md
```
### Contributing
1. Fork the repo and create a feature branch
2. Write tests for new functionality
3. Ensure `make lint` and `make test` pass
4. Submit a pull request
---
## License
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details.
---
<p align="center">
<strong>⚔️ Stay protected. Stay vigilant. ⚔️</strong>
</p>
+1
View File
@@ -0,0 +1 @@
__version__ = '1.0.0'
+4
View File
@@ -0,0 +1,4 @@
from ayn_antivirus.cli import main
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
"""Configuration loader for AYN Antivirus."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from ayn_antivirus.constants import (
DEFAULT_CONFIG_PATHS,
DEFAULT_DASHBOARD_DB_PATH,
DEFAULT_DASHBOARD_HOST,
DEFAULT_DASHBOARD_PASSWORD,
DEFAULT_DASHBOARD_PORT,
DEFAULT_DASHBOARD_USERNAME,
DEFAULT_DB_PATH,
DEFAULT_LOG_PATH,
DEFAULT_QUARANTINE_PATH,
DEFAULT_SCAN_PATH,
MAX_FILE_SIZE,
)
@dataclass
class Config:
"""Application configuration, loaded from YAML config files or environment variables."""
scan_paths: List[str] = field(default_factory=lambda: [DEFAULT_SCAN_PATH])
exclude_paths: List[str] = field(
default_factory=lambda: ["/proc", "/sys", "/dev", "/run", "/snap"]
)
quarantine_path: str = DEFAULT_QUARANTINE_PATH
db_path: str = DEFAULT_DB_PATH
log_path: str = DEFAULT_LOG_PATH
auto_quarantine: bool = False
scan_schedule: str = "0 2 * * *"
api_keys: Dict[str, str] = field(default_factory=dict)
max_file_size: int = MAX_FILE_SIZE
enable_yara: bool = True
enable_heuristics: bool = True
enable_realtime_monitor: bool = False
dashboard_host: str = DEFAULT_DASHBOARD_HOST
dashboard_port: int = DEFAULT_DASHBOARD_PORT
dashboard_db_path: str = DEFAULT_DASHBOARD_DB_PATH
dashboard_username: str = DEFAULT_DASHBOARD_USERNAME
dashboard_password: str = DEFAULT_DASHBOARD_PASSWORD
@classmethod
def load(cls, config_path: Optional[str] = None) -> Config:
"""Load configuration from a YAML file, then overlay environment variables.
Search order:
1. Explicit ``config_path`` argument.
2. /etc/ayn-antivirus/config.yaml
3. ~/.ayn-antivirus/config.yaml
4. Environment variables (always applied last as overrides).
"""
data: Dict[str, Any] = {}
paths_to_try = [config_path] if config_path else DEFAULT_CONFIG_PATHS
for path in paths_to_try:
if path and Path(path).is_file():
with open(path, "r") as fh:
data = yaml.safe_load(fh) or {}
break
defaults = cls()
config = cls(
scan_paths=data.get("scan_paths", defaults.scan_paths),
exclude_paths=data.get("exclude_paths", defaults.exclude_paths),
quarantine_path=data.get("quarantine_path", DEFAULT_QUARANTINE_PATH),
db_path=data.get("db_path", DEFAULT_DB_PATH),
log_path=data.get("log_path", DEFAULT_LOG_PATH),
auto_quarantine=data.get("auto_quarantine", False),
scan_schedule=data.get("scan_schedule", "0 2 * * *"),
api_keys=data.get("api_keys", {}),
max_file_size=data.get("max_file_size", MAX_FILE_SIZE),
enable_yara=data.get("enable_yara", True),
enable_heuristics=data.get("enable_heuristics", True),
enable_realtime_monitor=data.get("enable_realtime_monitor", False),
dashboard_host=data.get("dashboard_host", DEFAULT_DASHBOARD_HOST),
dashboard_port=data.get("dashboard_port", DEFAULT_DASHBOARD_PORT),
dashboard_db_path=data.get("dashboard_db_path", DEFAULT_DASHBOARD_DB_PATH),
dashboard_username=data.get("dashboard_username", DEFAULT_DASHBOARD_USERNAME),
dashboard_password=data.get("dashboard_password", DEFAULT_DASHBOARD_PASSWORD),
)
# --- Environment variable overrides ---
config._apply_env_overrides()
return config
def _apply_env_overrides(self) -> None:
"""Override config fields with AYN_* environment variables when set."""
if os.getenv("AYN_SCAN_PATH"):
self.scan_paths = [p.strip() for p in os.environ["AYN_SCAN_PATH"].split(",")]
if os.getenv("AYN_QUARANTINE_PATH"):
self.quarantine_path = os.environ["AYN_QUARANTINE_PATH"]
if os.getenv("AYN_DB_PATH"):
self.db_path = os.environ["AYN_DB_PATH"]
if os.getenv("AYN_LOG_PATH"):
self.log_path = os.environ["AYN_LOG_PATH"]
if os.getenv("AYN_AUTO_QUARANTINE"):
self.auto_quarantine = os.environ["AYN_AUTO_QUARANTINE"].lower() in (
"true",
"1",
"yes",
)
if os.getenv("AYN_SCAN_SCHEDULE"):
self.scan_schedule = os.environ["AYN_SCAN_SCHEDULE"]
if os.getenv("AYN_MALWAREBAZAAR_API_KEY"):
self.api_keys["malwarebazaar"] = os.environ["AYN_MALWAREBAZAAR_API_KEY"]
if os.getenv("AYN_VIRUSTOTAL_API_KEY"):
self.api_keys["virustotal"] = os.environ["AYN_VIRUSTOTAL_API_KEY"]
if os.getenv("AYN_MAX_FILE_SIZE"):
self.max_file_size = int(os.environ["AYN_MAX_FILE_SIZE"])
if os.getenv("AYN_DASHBOARD_HOST"):
self.dashboard_host = os.environ["AYN_DASHBOARD_HOST"]
if os.getenv("AYN_DASHBOARD_PORT"):
self.dashboard_port = int(os.environ["AYN_DASHBOARD_PORT"])
if os.getenv("AYN_DASHBOARD_DB_PATH"):
self.dashboard_db_path = os.environ["AYN_DASHBOARD_DB_PATH"]
if os.getenv("AYN_DASHBOARD_USERNAME"):
self.dashboard_username = os.environ["AYN_DASHBOARD_USERNAME"]
if os.getenv("AYN_DASHBOARD_PASSWORD"):
self.dashboard_password = os.environ["AYN_DASHBOARD_PASSWORD"]
+161
View File
@@ -0,0 +1,161 @@
"""Constants for AYN Antivirus."""
import os
# --- Default Paths ---
DEFAULT_CONFIG_PATHS = [
"/etc/ayn-antivirus/config.yaml",
os.path.expanduser("~/.ayn-antivirus/config.yaml"),
]
DEFAULT_SCAN_PATH = "/"
DEFAULT_QUARANTINE_PATH = "/var/lib/ayn-antivirus/quarantine"
DEFAULT_DB_PATH = "/var/lib/ayn-antivirus/signatures.db"
DEFAULT_LOG_PATH = "/var/log/ayn-antivirus/"
DEFAULT_YARA_RULES_DIR = os.path.join(os.path.dirname(__file__), "signatures", "yara_rules")
QUARANTINE_ENCRYPTION_KEY_FILE = "/var/lib/ayn-antivirus/.quarantine.key"
# --- Database ---
DB_SCHEMA_VERSION = 1
# --- Scan Limits ---
SCAN_CHUNK_SIZE = 65536 # 64 KB
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
HIGH_CPU_THRESHOLD = 80 # percent
# --- Suspicious File Extensions ---
SUSPICIOUS_EXTENSIONS = [
".php",
".sh",
".py",
".pl",
".rb",
".js",
".exe",
".elf",
".bin",
".so",
".dll",
]
# --- Crypto Miner Process Names ---
CRYPTO_MINER_PROCESS_NAMES = [
"xmrig",
"minerd",
"cpuminer",
"ethminer",
"claymore",
"phoenixminer",
"nbminer",
"t-rex",
"gminer",
"lolminer",
"bfgminer",
"cgminer",
"ccminer",
"nicehash",
"excavator",
"nanominer",
"teamredminer",
"wildrig",
"srbminer",
"xmr-stak",
"randomx",
"cryptonight",
]
# --- Crypto Pool Domains ---
CRYPTO_POOL_DOMAINS = [
"pool.minergate.com",
"xmrpool.eu",
"nanopool.org",
"mining.pool.observer",
"supportxmr.com",
"pool.hashvault.pro",
"moneroocean.stream",
"minexmr.com",
"herominers.com",
"2miners.com",
"f2pool.com",
"ethermine.org",
"unmineable.com",
"nicehash.com",
"prohashing.com",
"zpool.ca",
"miningpoolhub.com",
]
# --- Suspicious Mining Ports ---
SUSPICIOUS_PORTS = [
3333,
4444,
5555,
7777,
8888,
9999,
14433,
14444,
45560,
45700,
]
# --- Known Rootkit Files ---
KNOWN_ROOTKIT_FILES = [
"/usr/lib/libproc.so",
"/usr/lib/libext-2.so",
"/usr/lib/libns2.so",
"/usr/lib/libpam.so.1",
"/dev/shm/.x",
"/dev/shm/.r",
"/tmp/.ICE-unix/.x",
"/tmp/.X11-unix/.x",
"/usr/bin/sourcemask",
"/usr/bin/sshd2",
"/usr/sbin/xntpd",
"/etc/cron.d/.hidden",
"/var/tmp/.bash_history",
]
# --- Suspicious Cron Patterns ---
SUSPICIOUS_CRON_PATTERNS = [
r"curl\s+.*\|\s*sh",
r"wget\s+.*\|\s*sh",
r"curl\s+.*\|\s*bash",
r"wget\s+.*\|\s*bash",
r"/dev/tcp/",
r"base64\s+--decode",
r"xmrig",
r"minerd",
r"cryptonight",
r"\bcurl\b.*-o\s*/tmp/",
r"\bwget\b.*-O\s*/tmp/",
r"nohup\s+.*&",
r"/dev/null\s+2>&1",
]
# --- Malicious Environment Variables ---
MALICIOUS_ENV_VARS = [
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"LD_DEBUG",
"HISTFILE=/dev/null",
"PROMPT_COMMAND",
"BASH_ENV",
"ENV",
"CDPATH",
]
# ── Dashboard ──────────────────────────────────────────────────────────
DEFAULT_DASHBOARD_HOST = "0.0.0.0"
DEFAULT_DASHBOARD_PORT = 7777
DEFAULT_DASHBOARD_DB_PATH = "/var/lib/ayn-antivirus/dashboard.db"
DASHBOARD_COLLECTOR_INTERVAL = 10 # seconds between metric samples
DASHBOARD_REFRESH_INTERVAL = 30 # JS auto-refresh seconds
DASHBOARD_MAX_THREATS_DISPLAY = 50
DASHBOARD_MAX_LOG_LINES = 20
DASHBOARD_SCAN_HISTORY_DAYS = 30
DASHBOARD_METRIC_RETENTION_HOURS = 168 # 7 days
# Dashboard authentication
DEFAULT_DASHBOARD_USERNAME = "admin"
DEFAULT_DASHBOARD_PASSWORD = "ayn@2024"
+917
View File
@@ -0,0 +1,917 @@
"""Core scan engine for AYN Antivirus.
Orchestrates file-system, process, and network scanning by delegating to
pluggable detectors (hash lookup, YARA, heuristic) and emitting events via
the :pymod:`event_bus`.
"""
from __future__ import annotations
import logging
import os
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Protocol
from ayn_antivirus.config import Config
from ayn_antivirus.core.event_bus import EventType, event_bus
from ayn_antivirus.utils.helpers import hash_file as _hash_file_util
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class ThreatType(Enum):
"""Classification of a detected threat."""
VIRUS = auto()
MALWARE = auto()
SPYWARE = auto()
MINER = auto()
ROOTKIT = auto()
class Severity(Enum):
"""Threat severity level, ordered low → critical."""
LOW = 1
MEDIUM = 2
HIGH = 3
CRITICAL = 4
class ScanType(Enum):
"""Kind of scan that was executed."""
FULL = "full"
QUICK = "quick"
DEEP = "deep"
SINGLE_FILE = "single_file"
TARGETED = "targeted"
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ThreatInfo:
"""A single threat detected during a file scan."""
path: str
threat_name: str
threat_type: ThreatType
severity: Severity
detector_name: str
details: str = ""
timestamp: datetime = field(default_factory=datetime.utcnow)
file_hash: str = ""
@dataclass
class FileScanResult:
"""Result of scanning a single file."""
path: str
scanned: bool = True
file_hash: str = ""
size: int = 0
threats: List[ThreatInfo] = field(default_factory=list)
error: Optional[str] = None
@property
def is_clean(self) -> bool:
return len(self.threats) == 0 and self.error is None
@dataclass
class ProcessThreat:
"""A suspicious process discovered at runtime."""
pid: int
name: str
cmdline: str
cpu_percent: float
memory_percent: float
threat_type: ThreatType
severity: Severity
details: str = ""
@dataclass
class NetworkThreat:
"""A suspicious network connection."""
local_addr: str
remote_addr: str
pid: Optional[int]
process_name: str
threat_type: ThreatType
severity: Severity
details: str = ""
@dataclass
class ScanResult:
"""Aggregated result of a path / multi-file scan."""
scan_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
start_time: datetime = field(default_factory=datetime.utcnow)
end_time: Optional[datetime] = None
files_scanned: int = 0
files_skipped: int = 0
threats: List[ThreatInfo] = field(default_factory=list)
scan_path: str = ""
scan_type: ScanType = ScanType.FULL
@property
def duration_seconds(self) -> float:
if self.end_time is None:
return 0.0
return (self.end_time - self.start_time).total_seconds()
@property
def is_clean(self) -> bool:
return len(self.threats) == 0
@dataclass
class ProcessScanResult:
"""Aggregated result of a process scan."""
processes_scanned: int = 0
threats: List[ProcessThreat] = field(default_factory=list)
scan_duration: float = 0.0
@property
def total_processes(self) -> int:
"""Alias for processes_scanned (backward compat)."""
return self.processes_scanned
@property
def is_clean(self) -> bool:
return len(self.threats) == 0
@dataclass
class NetworkScanResult:
"""Aggregated result of a network scan."""
connections_scanned: int = 0
threats: List[NetworkThreat] = field(default_factory=list)
scan_duration: float = 0.0
@property
def total_connections(self) -> int:
"""Alias for connections_scanned (backward compat)."""
return self.connections_scanned
@property
def is_clean(self) -> bool:
return len(self.threats) == 0
@dataclass
class FullScanResult:
"""Combined results from a full scan (files + processes + network + containers)."""
file_scan: ScanResult = field(default_factory=ScanResult)
process_scan: ProcessScanResult = field(default_factory=ProcessScanResult)
network_scan: NetworkScanResult = field(default_factory=NetworkScanResult)
container_scan: Any = None # Optional[ContainerScanResult]
@property
def total_threats(self) -> int:
count = (
len(self.file_scan.threats)
+ len(self.process_scan.threats)
+ len(self.network_scan.threats)
)
if self.container_scan is not None:
count += len(self.container_scan.threats)
return count
@property
def is_clean(self) -> bool:
return self.total_threats == 0
# ---------------------------------------------------------------------------
# Detector protocol (for type hints & documentation)
# ---------------------------------------------------------------------------
class _Detector(Protocol):
"""Any object with a ``detect()`` method matching the BaseDetector API."""
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> list: ...
# ---------------------------------------------------------------------------
# Helper: file hashing
# ---------------------------------------------------------------------------
def _hash_file(filepath: Path, algo: str = "sha256") -> str:
"""Return the hex digest of *filepath*.
Delegates to :func:`ayn_antivirus.utils.helpers.hash_file`.
"""
return _hash_file_util(filepath, algo)
# ---------------------------------------------------------------------------
# Detector result → engine dataclass mapping
# ---------------------------------------------------------------------------
_THREAT_TYPE_MAP = {
"VIRUS": ThreatType.VIRUS,
"MALWARE": ThreatType.MALWARE,
"SPYWARE": ThreatType.SPYWARE,
"MINER": ThreatType.MINER,
"ROOTKIT": ThreatType.ROOTKIT,
"HEURISTIC": ThreatType.MALWARE,
}
_SEVERITY_MAP = {
"CRITICAL": Severity.CRITICAL,
"HIGH": Severity.HIGH,
"MEDIUM": Severity.MEDIUM,
"LOW": Severity.LOW,
}
def _map_threat_type(raw: str) -> ThreatType:
"""Convert a detector's threat-type string to :class:`ThreatType`."""
return _THREAT_TYPE_MAP.get(raw.upper(), ThreatType.MALWARE)
def _map_severity(raw: str) -> Severity:
"""Convert a detector's severity string to :class:`Severity`."""
return _SEVERITY_MAP.get(raw.upper(), Severity.MEDIUM)
# ---------------------------------------------------------------------------
# Quick-scan target directories
# ---------------------------------------------------------------------------
QUICK_SCAN_PATHS = [
"/tmp",
"/var/tmp",
"/dev/shm",
"/usr/local/bin",
"/var/spool/cron",
"/etc/cron.d",
"/etc/cron.daily",
"/etc/crontab",
"/var/www",
"/srv",
]
# ---------------------------------------------------------------------------
# ScanEngine
# ---------------------------------------------------------------------------
class ScanEngine:
"""Central orchestrator for all AYN scanning activities.
The engine walks the file system, delegates to pluggable detectors, tracks
statistics, and publishes events on the global :pydata:`event_bus`.
Parameters
----------
config:
Application configuration instance.
max_workers:
Thread pool size for parallel file scanning. Defaults to
``min(os.cpu_count(), 8)``.
"""
def __init__(self, config: Config, max_workers: int | None = None) -> None:
self.config = config
self.max_workers = max_workers or min(os.cpu_count() or 4, 8)
# Detector registry — populated by external plug-ins via register_detector().
# Each detector is a callable: (filepath: Path, cfg: Config) -> List[ThreatInfo]
self._detectors: List[_Detector] = []
self._init_builtin_detectors()
# ------------------------------------------------------------------
# Detector registration
# ------------------------------------------------------------------
def register_detector(self, detector: _Detector) -> None:
"""Add a detector to the scanning pipeline."""
self._detectors.append(detector)
def _init_builtin_detectors(self) -> None:
"""Register all built-in detection engines."""
from ayn_antivirus.detectors.signature_detector import SignatureDetector
from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector
from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector
from ayn_antivirus.detectors.spyware_detector import SpywareDetector
from ayn_antivirus.detectors.rootkit_detector import RootkitDetector
try:
sig_det = SignatureDetector(db_path=self.config.db_path)
self.register_detector(sig_det)
except Exception as e:
logger.warning("Failed to load SignatureDetector: %s", e)
try:
self.register_detector(HeuristicDetector())
except Exception as e:
logger.warning("Failed to load HeuristicDetector: %s", e)
try:
self.register_detector(CryptominerDetector())
except Exception as e:
logger.warning("Failed to load CryptominerDetector: %s", e)
try:
self.register_detector(SpywareDetector())
except Exception as e:
logger.warning("Failed to load SpywareDetector: %s", e)
try:
self.register_detector(RootkitDetector())
except Exception as e:
logger.warning("Failed to load RootkitDetector: %s", e)
if self.config.enable_yara:
try:
from ayn_antivirus.detectors.yara_detector import YaraDetector
yara_det = YaraDetector()
self.register_detector(yara_det)
except Exception as e:
logger.debug("YARA detector not available: %s", e)
logger.info("Registered %d detectors", len(self._detectors))
# ------------------------------------------------------------------
# File scanning
# ------------------------------------------------------------------
def scan_file(self, filepath: str | Path) -> FileScanResult:
"""Scan a single file through every registered detector.
Parameters
----------
filepath:
Absolute or relative path to the file.
Returns
-------
FileScanResult
"""
filepath = Path(filepath)
result = FileScanResult(path=str(filepath))
if not filepath.is_file():
result.scanned = False
result.error = "Not a file or does not exist"
return result
try:
stat = filepath.stat()
except OSError as exc:
result.scanned = False
result.error = str(exc)
return result
result.size = stat.st_size
if result.size > self.config.max_file_size:
result.scanned = False
result.error = f"File exceeds max size ({result.size} > {self.config.max_file_size})"
return result
# Hash the file — needed by hash-based detectors and for recording.
try:
result.file_hash = _hash_file(filepath)
except OSError as exc:
result.scanned = False
result.error = f"Cannot read file: {exc}"
return result
# Enrich with FileScanner metadata (type classification).
try:
from ayn_antivirus.scanners.file_scanner import FileScanner
file_scanner = FileScanner(max_file_size=self.config.max_file_size)
file_info = file_scanner.scan(str(filepath))
result._file_info = file_info # type: ignore[attr-defined]
except Exception:
logger.debug("FileScanner enrichment skipped for %s", filepath)
# Run every registered detector.
for detector in self._detectors:
try:
detections = detector.detect(filepath, file_hash=result.file_hash)
for d in detections:
threat = ThreatInfo(
path=str(filepath),
threat_name=d.threat_name,
threat_type=_map_threat_type(d.threat_type),
severity=_map_severity(d.severity),
detector_name=d.detector_name,
details=d.details,
file_hash=result.file_hash,
)
result.threats.append(threat)
except Exception:
logger.exception("Detector %r failed on %s", detector, filepath)
# Publish per-file events.
event_bus.publish(EventType.FILE_SCANNED, result)
if result.threats:
for threat in result.threats:
event_bus.publish(EventType.THREAT_FOUND, threat)
return result
# ------------------------------------------------------------------
# Path scanning (recursive)
# ------------------------------------------------------------------
def scan_path(
self,
path: str | Path,
recursive: bool = True,
quick: bool = False,
callback: Optional[Callable[[FileScanResult], None]] = None,
) -> ScanResult:
"""Walk *path* and scan every eligible file.
Parameters
----------
path:
Root directory (or single file) to scan.
recursive:
Descend into subdirectories.
quick:
If ``True``, only scan :pydata:`QUICK_SCAN_PATHS` that exist
under *path* (or the quick-scan list itself when *path* is ``/``).
callback:
Optional function called after each file is scanned — useful for
progress reporting.
Returns
-------
ScanResult
"""
scan_type = ScanType.QUICK if quick else ScanType.FULL
result = ScanResult(
scan_path=str(path),
scan_type=scan_type,
start_time=datetime.utcnow(),
)
event_bus.publish(EventType.SCAN_STARTED, {
"scan_id": result.scan_id,
"scan_type": scan_type.value,
"path": str(path),
})
# Collect files to scan.
files = self._collect_files(Path(path), recursive=recursive, quick=quick)
# Parallel scan.
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
futures = {pool.submit(self.scan_file, fp): fp for fp in files}
for future in as_completed(futures):
try:
file_result = future.result()
except Exception:
result.files_skipped += 1
logger.exception("Unhandled error scanning %s", futures[future])
continue
if file_result.scanned:
result.files_scanned += 1
else:
result.files_skipped += 1
result.threats.extend(file_result.threats)
if callback is not None:
try:
callback(file_result)
except Exception:
logger.exception("Scan callback raised an exception")
result.end_time = datetime.utcnow()
event_bus.publish(EventType.SCAN_COMPLETED, {
"scan_id": result.scan_id,
"files_scanned": result.files_scanned,
"threats": len(result.threats),
"duration": result.duration_seconds,
})
return result
# ------------------------------------------------------------------
# Process scanning
# ------------------------------------------------------------------
def scan_processes(self) -> ProcessScanResult:
"""Inspect all running processes for known miners and anomalies.
Delegates to :class:`~ayn_antivirus.scanners.process_scanner.ProcessScanner`
for detection and converts results to engine dataclasses.
Returns
-------
ProcessScanResult
"""
from ayn_antivirus.scanners.process_scanner import ProcessScanner
result = ProcessScanResult()
start = time.monotonic()
proc_scanner = ProcessScanner()
scan_data = proc_scanner.scan()
result.processes_scanned = scan_data.get("total", 0)
# Known miner matches.
for s in scan_data.get("suspicious", []):
threat = ProcessThreat(
pid=s["pid"],
name=s.get("name", ""),
cmdline=" ".join(s.get("cmdline") or []),
cpu_percent=s.get("cpu_percent", 0.0),
memory_percent=0.0,
threat_type=ThreatType.MINER,
severity=Severity.CRITICAL,
details=s.get("reason", "Known miner process"),
)
result.threats.append(threat)
event_bus.publish(EventType.THREAT_FOUND, threat)
# High-CPU anomalies (skip duplicates already caught as miners).
miner_pids = {t.pid for t in result.threats}
for h in scan_data.get("high_cpu", []):
if h["pid"] in miner_pids:
continue
threat = ProcessThreat(
pid=h["pid"],
name=h.get("name", ""),
cmdline=" ".join(h.get("cmdline") or []),
cpu_percent=h.get("cpu_percent", 0.0),
memory_percent=0.0,
threat_type=ThreatType.MINER,
severity=Severity.HIGH,
details=h.get("reason", "Abnormally high CPU usage"),
)
result.threats.append(threat)
event_bus.publish(EventType.THREAT_FOUND, threat)
# Hidden processes (possible rootkit).
for hp in scan_data.get("hidden", []):
threat = ProcessThreat(
pid=hp["pid"],
name=hp.get("name", ""),
cmdline=hp.get("cmdline", ""),
cpu_percent=0.0,
memory_percent=0.0,
threat_type=ThreatType.ROOTKIT,
severity=Severity.CRITICAL,
details=hp.get("reason", "Hidden process"),
)
result.threats.append(threat)
event_bus.publish(EventType.THREAT_FOUND, threat)
# Optional memory scan for suspicious PIDs.
try:
from ayn_antivirus.scanners.memory_scanner import MemoryScanner
mem_scanner = MemoryScanner()
suspicious_pids = {t.pid for t in result.threats}
for pid in suspicious_pids:
try:
mem_result = mem_scanner.scan(pid)
rwx_regions = mem_result.get("rwx_regions") or []
if rwx_regions:
result.threats.append(ProcessThreat(
pid=pid,
name="",
cmdline="",
cpu_percent=0.0,
memory_percent=0.0,
threat_type=ThreatType.ROOTKIT,
severity=Severity.HIGH,
details=(
f"Injected code detected in PID {pid}: "
f"{len(rwx_regions)} RWX region(s)"
),
))
except Exception:
pass # Memory scan for individual PID is best-effort
except Exception as exc:
logger.debug("Memory scan skipped: %s", exc)
result.scan_duration = time.monotonic() - start
return result
# ------------------------------------------------------------------
# Network scanning
# ------------------------------------------------------------------
def scan_network(self) -> NetworkScanResult:
"""Scan active network connections for mining pool traffic.
Delegates to :class:`~ayn_antivirus.scanners.network_scanner.NetworkScanner`
for detection and converts results to engine dataclasses.
Returns
-------
NetworkScanResult
"""
from ayn_antivirus.scanners.network_scanner import NetworkScanner
result = NetworkScanResult()
start = time.monotonic()
net_scanner = NetworkScanner()
scan_data = net_scanner.scan()
result.connections_scanned = scan_data.get("total", 0)
# Suspicious connections (mining pools, suspicious ports).
for s in scan_data.get("suspicious", []):
sev = _map_severity(s.get("severity", "HIGH"))
threat = NetworkThreat(
local_addr=s.get("local_addr", "?"),
remote_addr=s.get("remote_addr", "?"),
pid=s.get("pid"),
process_name=(s.get("process", {}) or {}).get("name", ""),
threat_type=ThreatType.MINER,
severity=sev,
details=s.get("reason", "Suspicious connection"),
)
result.threats.append(threat)
event_bus.publish(EventType.THREAT_FOUND, threat)
# Unexpected listening ports.
for lp in scan_data.get("unexpected_listeners", []):
threat = NetworkThreat(
local_addr=lp.get("local_addr", f"?:{lp.get('port', '?')}"),
remote_addr="",
pid=lp.get("pid"),
process_name=lp.get("process_name", ""),
threat_type=ThreatType.MALWARE,
severity=_map_severity(lp.get("severity", "MEDIUM")),
details=lp.get("reason", "Unexpected listener"),
)
result.threats.append(threat)
event_bus.publish(EventType.THREAT_FOUND, threat)
# Enrich with IOC database lookups — flag connections to known-bad IPs.
try:
from ayn_antivirus.signatures.db.ioc_db import IOCDatabase
ioc_db = IOCDatabase(self.config.db_path)
ioc_db.initialize()
malicious_ips = ioc_db.get_all_malicious_ips()
if malicious_ips:
import psutil as _psutil
already_flagged = {
t.remote_addr for t in result.threats
}
try:
for conn in _psutil.net_connections(kind="inet"):
if not conn.raddr:
continue
remote_ip = conn.raddr.ip
remote_str = f"{remote_ip}:{conn.raddr.port}"
if remote_ip in malicious_ips and remote_str not in already_flagged:
ioc_info = ioc_db.lookup_ip(remote_ip) or {}
result.threats.append(NetworkThreat(
local_addr=(
f"{conn.laddr.ip}:{conn.laddr.port}"
if conn.laddr else ""
),
remote_addr=remote_str,
pid=conn.pid or 0,
process_name=self._get_proc_name(conn.pid),
threat_type=ThreatType.MALWARE,
severity=Severity.CRITICAL,
details=(
f"Connection to known malicious IP {remote_ip} "
f"(threat: {ioc_info.get('threat_name', 'IOC match')})"
),
))
except (_psutil.AccessDenied, OSError):
pass
ioc_db.close()
except Exception as exc:
logger.debug("IOC network enrichment skipped: %s", exc)
result.scan_duration = time.monotonic() - start
return result
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _get_proc_name(pid: int) -> str:
"""Best-effort process name lookup for a PID."""
if not pid:
return ""
try:
import psutil as _ps
return _ps.Process(pid).name()
except Exception:
return ""
# ------------------------------------------------------------------
# Container scanning
# ------------------------------------------------------------------
def scan_containers(
self,
runtime: str = "all",
container_id: Optional[str] = None,
):
"""Scan containers for threats.
Parameters
----------
runtime:
Container runtime to target (``"all"``, ``"docker"``,
``"podman"``, ``"lxc"``).
container_id:
If provided, scan only this specific container.
Returns
-------
ContainerScanResult
"""
from ayn_antivirus.scanners.container_scanner import ContainerScanner
scanner = ContainerScanner()
if container_id:
return scanner.scan_container(container_id)
return scanner.scan(runtime)
# ------------------------------------------------------------------
# Composite scans
# ------------------------------------------------------------------
def full_scan(
self,
callback: Optional[Callable[[FileScanResult], None]] = None,
) -> FullScanResult:
"""Run a complete scan: files, processes, and network.
Parameters
----------
callback:
Optional per-file progress callback.
Returns
-------
FullScanResult
"""
full = FullScanResult()
# File scan across all configured paths.
aggregate = ScanResult(scan_type=ScanType.FULL, start_time=datetime.utcnow())
for scan_path in self.config.scan_paths:
partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback)
aggregate.files_scanned += partial.files_scanned
aggregate.files_skipped += partial.files_skipped
aggregate.threats.extend(partial.threats)
aggregate.end_time = datetime.utcnow()
full.file_scan = aggregate
# Process + network.
full.process_scan = self.scan_processes()
full.network_scan = self.scan_network()
# Containers (best-effort — skipped if no runtimes available).
try:
container_result = self.scan_containers()
if container_result.containers_found > 0:
full.container_scan = container_result
except Exception:
logger.debug("Container scanning skipped", exc_info=True)
return full
def quick_scan(
self,
callback: Optional[Callable[[FileScanResult], None]] = None,
) -> ScanResult:
"""Scan only high-risk directories.
Targets :pydata:`QUICK_SCAN_PATHS` and any additional web roots
or crontab locations.
Returns
-------
ScanResult
"""
aggregate = ScanResult(scan_type=ScanType.QUICK, start_time=datetime.utcnow())
event_bus.publish(EventType.SCAN_STARTED, {
"scan_id": aggregate.scan_id,
"scan_type": "quick",
"paths": QUICK_SCAN_PATHS,
})
for scan_path in QUICK_SCAN_PATHS:
p = Path(scan_path)
if not p.exists():
continue
partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback)
aggregate.files_scanned += partial.files_scanned
aggregate.files_skipped += partial.files_skipped
aggregate.threats.extend(partial.threats)
aggregate.end_time = datetime.utcnow()
event_bus.publish(EventType.SCAN_COMPLETED, {
"scan_id": aggregate.scan_id,
"files_scanned": aggregate.files_scanned,
"threats": len(aggregate.threats),
"duration": aggregate.duration_seconds,
})
return aggregate
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _collect_files(
self,
root: Path,
recursive: bool = True,
quick: bool = False,
) -> List[Path]:
"""Walk *root* and return a list of scannable file paths.
Respects ``config.exclude_paths`` and ``config.max_file_size``.
"""
targets: List[Path] = []
if quick:
# In quick mode, only descend into known-risky subdirectories.
roots = [
root / rel
for rel in (
"tmp", "var/tmp", "dev/shm", "usr/local/bin",
"var/spool/cron", "etc/cron.d", "etc/cron.daily",
"var/www", "srv",
)
if (root / rel).exists()
]
# Also include the quick-scan list itself if root is /.
if str(root) == "/":
roots = [Path(p) for p in QUICK_SCAN_PATHS if Path(p).exists()]
else:
roots = [root]
exclude = set(self.config.exclude_paths)
for r in roots:
if r.is_file():
targets.append(r)
continue
iterator = r.rglob("*") if recursive else r.iterdir()
try:
for entry in iterator:
if not entry.is_file():
continue
# Exclude check.
entry_str = str(entry)
if any(entry_str.startswith(ex) for ex in exclude):
continue
try:
if entry.stat().st_size > self.config.max_file_size:
continue
except OSError:
continue
targets.append(entry)
except PermissionError:
logger.warning("Permission denied: %s", r)
return targets
@@ -0,0 +1,119 @@
"""Simple publish/subscribe event bus for AYN Antivirus.
Decouples the scan engine from consumers like the CLI, logger, quarantine
manager, and real-time monitor so each component can react to events
independently.
"""
from __future__ import annotations
import logging
import threading
from enum import Enum, auto
from typing import Any, Callable, Dict, List
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------
class EventType(Enum):
"""All events emitted by the AYN engine."""
THREAT_FOUND = auto()
SCAN_STARTED = auto()
SCAN_COMPLETED = auto()
FILE_SCANNED = auto()
SIGNATURE_UPDATED = auto()
QUARANTINE_ACTION = auto()
REMEDIATION_ACTION = auto()
DASHBOARD_METRIC = auto()
# Type alias for subscriber callbacks.
Callback = Callable[[EventType, Any], None]
# ---------------------------------------------------------------------------
# EventBus
# ---------------------------------------------------------------------------
class EventBus:
"""Thread-safe publish/subscribe event bus.
Usage::
bus = EventBus()
bus.subscribe(EventType.THREAT_FOUND, lambda et, data: print(data))
bus.publish(EventType.THREAT_FOUND, {"path": "/tmp/evil.elf"})
"""
def __init__(self) -> None:
self._subscribers: Dict[EventType, List[Callback]] = {et: [] for et in EventType}
self._lock = threading.Lock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def subscribe(self, event_type: EventType, callback: Callback) -> None:
"""Register *callback* to be invoked whenever *event_type* is published.
Parameters
----------
event_type:
The event to listen for.
callback:
A callable with signature ``(event_type, data) -> None``.
"""
with self._lock:
if callback not in self._subscribers[event_type]:
self._subscribers[event_type].append(callback)
def unsubscribe(self, event_type: EventType, callback: Callback) -> None:
"""Remove a previously-registered callback."""
with self._lock:
try:
self._subscribers[event_type].remove(callback)
except ValueError:
pass
def publish(self, event_type: EventType, data: Any = None) -> None:
"""Emit an event, invoking all registered callbacks synchronously.
Exceptions raised by individual callbacks are logged and swallowed so
that one faulty subscriber cannot break the pipeline.
Parameters
----------
event_type:
The event being emitted.
data:
Arbitrary payload — typically a dataclass or dict.
"""
with self._lock:
callbacks = list(self._subscribers[event_type])
for cb in callbacks:
try:
cb(event_type, data)
except Exception:
logger.exception(
"Subscriber %r raised an exception for event %s",
cb,
event_type.name,
)
def clear(self, event_type: EventType | None = None) -> None:
"""Remove all subscribers for *event_type*, or all subscribers if ``None``."""
with self._lock:
if event_type is None:
for et in EventType:
self._subscribers[et].clear()
else:
self._subscribers[event_type].clear()
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
event_bus = EventBus()
@@ -0,0 +1,215 @@
"""Scheduler for recurring scans and signature updates.
Wraps the ``schedule`` library to provide cron-like recurring tasks that
drive the :class:`ScanEngine` and signature updater in a long-running
daemon loop.
"""
from __future__ import annotations
import logging
import time
from typing import Optional
import schedule
from ayn_antivirus.config import Config
from ayn_antivirus.core.engine import ScanEngine, ScanResult
from ayn_antivirus.core.event_bus import EventType, event_bus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Cron expression helpers
# ---------------------------------------------------------------------------
def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]:
"""Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``).
Returns a sorted list of matching integer values.
"""
values: set[int] = set()
for part in field.split(","):
part = part.strip()
# */step
if part.startswith("*/"):
step = int(part[2:])
values.update(range(min_val, max_val + 1, step))
# range with optional step (e.g. 1-5 or 1-5/2)
elif "-" in part:
range_part, _, step_part = part.partition("/")
lo, hi = range_part.split("-", 1)
step = int(step_part) if step_part else 1
values.update(range(int(lo), int(hi) + 1, step))
# wildcard
elif part == "*":
values.update(range(min_val, max_val + 1))
# literal
else:
values.add(int(part))
return sorted(values)
def _cron_to_schedule(cron_expr: str) -> dict:
"""Convert a 5-field cron expression into components.
Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``,
``weekdays`` — each a list of integers.
Only *minute* and *hour* are used by the ``schedule`` library adapter
below; the rest are validated but not fully honoured (``schedule`` lacks
calendar-level granularity).
"""
parts = cron_expr.strip().split()
if len(parts) != 5:
raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}")
return {
"minutes": _parse_cron_field(parts[0], 0, 59),
"hours": _parse_cron_field(parts[1], 0, 23),
"days": _parse_cron_field(parts[2], 1, 31),
"months": _parse_cron_field(parts[3], 1, 12),
"weekdays": _parse_cron_field(parts[4], 0, 6),
}
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class Scheduler:
"""Manages recurring scan and update jobs.
Parameters
----------
config:
Application configuration — used to build a :class:`ScanEngine` and
read schedule expressions.
engine:
Optional pre-built engine instance. If ``None``, one is created from
*config*.
"""
def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None:
self.config = config
self.engine = engine or ScanEngine(config)
self._scheduler = schedule.Scheduler()
# ------------------------------------------------------------------
# Job builders
# ------------------------------------------------------------------
def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None:
"""Schedule a recurring scan using a cron expression.
Parameters
----------
cron_expr:
Standard 5-field cron string (``minute hour dom month dow``).
scan_type:
One of ``"full"``, ``"quick"``, or ``"deep"``.
"""
parsed = _cron_to_schedule(cron_expr)
# ``schedule`` doesn't natively support cron, so we approximate by
# scheduling at every matching hour:minute combination. For simple
# expressions like ``0 2 * * *`` this is exact.
for hour in parsed["hours"]:
for minute in parsed["minutes"]:
time_str = f"{hour:02d}:{minute:02d}"
self._scheduler.every().day.at(time_str).do(
self._run_scan, scan_type=scan_type
)
logger.info("Scheduled %s scan at %s daily", scan_type, time_str)
def schedule_update(self, interval_hours: int = 6) -> None:
"""Schedule recurring signature updates.
Parameters
----------
interval_hours:
How often (in hours) to pull fresh signatures.
"""
self._scheduler.every(interval_hours).hours.do(self._run_update)
logger.info("Scheduled signature update every %d hour(s)", interval_hours)
# ------------------------------------------------------------------
# Daemon loop
# ------------------------------------------------------------------
def run_daemon(self) -> None:
"""Start the blocking scheduler loop.
Runs all pending jobs and sleeps between iterations. Designed to be
the main loop of a background daemon process.
Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly.
"""
logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs()))
try:
while True:
self._scheduler.run_pending()
time.sleep(30)
except KeyboardInterrupt:
logger.info("Scheduler daemon stopped by user")
# ------------------------------------------------------------------
# Job implementations
# ------------------------------------------------------------------
def _run_scan(self, scan_type: str = "full") -> None:
"""Execute a scan job."""
logger.info("Starting scheduled %s scan", scan_type)
try:
if scan_type == "quick":
result: ScanResult = self.engine.quick_scan()
else:
# "full" and "deep" both scan all paths; deep adds process/network
# via full_scan on the engine, but here we keep it simple.
result = ScanResult()
for path in self.config.scan_paths:
partial = self.engine.scan_path(path, recursive=True)
result.files_scanned += partial.files_scanned
result.files_skipped += partial.files_skipped
result.threats.extend(partial.threats)
logger.info(
"Scheduled %s scan complete — %d files, %d threats",
scan_type,
result.files_scanned,
len(result.threats),
)
except Exception:
logger.exception("Scheduled %s scan failed", scan_type)
def _run_update(self) -> None:
"""Execute a signature update job."""
logger.info("Starting scheduled signature update")
try:
from ayn_antivirus.signatures.manager import SignatureManager
manager = SignatureManager(self.config)
summary = manager.update_all()
total = summary.get("total_new", 0)
errors = summary.get("errors", [])
logger.info(
"Scheduled signature update complete: %d new, %d errors",
total,
len(errors),
)
if errors:
for err in errors:
logger.warning("Feed error: %s", err)
manager.close()
event_bus.publish(EventType.SIGNATURE_UPDATED, {
"total_new": total,
"feeds": list(summary.get("feeds", {}).keys()),
"errors": errors,
})
except Exception:
logger.exception("Scheduled signature update failed")
@@ -0,0 +1,7 @@
"""AYN Antivirus - Live Web Dashboard."""
from ayn_antivirus.dashboard.collector import MetricsCollector
from ayn_antivirus.dashboard.server import DashboardServer
from ayn_antivirus.dashboard.store import DashboardStore
__all__ = ["DashboardServer", "DashboardStore", "MetricsCollector"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
"""Background metrics collector for the AYN Antivirus dashboard."""
from __future__ import annotations
import asyncio
import logging
import os
import random
from datetime import datetime
from typing import Any, Dict, Optional
import psutil
from ayn_antivirus.constants import DASHBOARD_COLLECTOR_INTERVAL
logger = logging.getLogger("ayn_antivirus.dashboard.collector")
class MetricsCollector:
"""Periodically sample system metrics and store them in the dashboard DB.
Parameters
----------
store:
A :class:`DashboardStore` instance to write metrics into.
interval:
Seconds between samples.
"""
def __init__(self, store: Any, interval: int = DASHBOARD_COLLECTOR_INTERVAL) -> None:
self.store = store
self.interval = interval
self._task: Optional[asyncio.Task] = None
self._running = False
async def start(self) -> None:
"""Begin collecting metrics on a background asyncio task."""
self._running = True
self._task = asyncio.create_task(self._collect_loop())
logger.info("Metrics collector started (interval=%ds)", self.interval)
async def stop(self) -> None:
"""Cancel the background task and wait for it to finish."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Metrics collector stopped")
# ------------------------------------------------------------------
# Internal loop
# ------------------------------------------------------------------
async def _collect_loop(self) -> None:
while self._running:
try:
await asyncio.to_thread(self._sample)
except Exception as exc:
logger.error("Collector error: %s", exc)
await asyncio.sleep(self.interval)
def _sample(self) -> None:
"""Take a single metric snapshot and persist it."""
cpu = psutil.cpu_percent(interval=1)
mem = psutil.virtual_memory()
disks = []
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
disks.append({
"mount": part.mountpoint,
"device": part.device,
"total": usage.total,
"used": usage.used,
"free": usage.free,
"percent": usage.percent,
})
except (PermissionError, OSError):
continue
try:
load = list(os.getloadavg())
except (OSError, AttributeError):
load = [0.0, 0.0, 0.0]
try:
net_conns = len(psutil.net_connections(kind="inet"))
except (psutil.AccessDenied, OSError):
net_conns = 0
self.store.record_metric(
cpu=cpu,
mem_pct=mem.percent,
mem_used=mem.used,
mem_total=mem.total,
disk_usage=disks,
load_avg=load,
net_conns=net_conns,
)
# Periodic cleanup (~1 in 100 samples).
if random.randint(1, 100) == 1:
self.store.cleanup_old_metrics()
# ------------------------------------------------------------------
# One-shot snapshot (no storage)
# ------------------------------------------------------------------
@staticmethod
def get_snapshot() -> Dict[str, Any]:
"""Return a live system snapshot without persisting it."""
cpu = psutil.cpu_percent(interval=0.1)
cpu_per_core = psutil.cpu_percent(interval=0.1, percpu=True)
cpu_freq = psutil.cpu_freq(percpu=False)
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
disks = []
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
disks.append({
"mount": part.mountpoint,
"device": part.device,
"total": usage.total,
"used": usage.used,
"percent": usage.percent,
})
except (PermissionError, OSError):
continue
try:
load = list(os.getloadavg())
except (OSError, AttributeError):
load = [0.0, 0.0, 0.0]
try:
net_conns = len(psutil.net_connections(kind="inet"))
except (psutil.AccessDenied, OSError):
net_conns = 0
# Top processes by CPU
top_procs = []
try:
for p in sorted(psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']),
key=lambda x: x.info.get('cpu_percent', 0) or 0, reverse=True)[:8]:
info = p.info
if (info.get('cpu_percent') or 0) > 0.1:
top_procs.append({
"pid": info['pid'],
"name": info['name'] or '?',
"cpu": round(info.get('cpu_percent', 0) or 0, 1),
"mem": round(info.get('memory_percent', 0) or 0, 1),
})
except Exception:
pass
return {
"cpu_percent": cpu,
"cpu_per_core": cpu_per_core,
"cpu_cores": psutil.cpu_count(logical=True),
"cpu_freq_mhz": round(cpu_freq.current) if cpu_freq else 0,
"mem_percent": mem.percent,
"mem_used": mem.used,
"mem_total": mem.total,
"mem_available": mem.available,
"mem_cached": getattr(mem, 'cached', 0),
"mem_buffers": getattr(mem, 'buffers', 0),
"swap_percent": swap.percent,
"swap_used": swap.used,
"swap_total": swap.total,
"disk_usage": disks,
"load_avg": load,
"net_connections": net_conns,
"top_processes": top_procs,
"timestamp": datetime.utcnow().isoformat(),
}
@@ -0,0 +1,427 @@
"""AYN Antivirus Dashboard — Web Server with Password Auth.
Lightweight aiohttp server that serves the dashboard SPA and REST API.
Non-localhost access requires username/password authentication via a
session cookie obtained through ``POST /login``.
"""
from __future__ import annotations
import logging
import secrets
import time
from typing import Dict, Optional
from urllib.parse import urlparse
from aiohttp import web
from ayn_antivirus.config import Config
from ayn_antivirus.constants import QUARANTINE_ENCRYPTION_KEY_FILE
from ayn_antivirus.dashboard.api import setup_routes
from ayn_antivirus.dashboard.collector import MetricsCollector
from ayn_antivirus.dashboard.store import DashboardStore
from ayn_antivirus.dashboard.templates import get_dashboard_html
logger = logging.getLogger("ayn_antivirus.dashboard.server")
# ------------------------------------------------------------------
# JSON error handler — prevent aiohttp returning HTML on /api/* routes
# ------------------------------------------------------------------
@web.middleware
async def json_error_middleware(
request: web.Request,
handler,
) -> web.StreamResponse:
"""Catch unhandled exceptions and return JSON for API routes.
Without this, aiohttp's default error handler returns HTML error
pages, which break frontend ``fetch().json()`` calls.
"""
try:
return await handler(request)
except web.HTTPException as exc:
if request.path.startswith("/api/"):
return web.json_response(
{"error": exc.reason or "Request failed"},
status=exc.status,
)
raise
except Exception as exc:
logger.exception("Unhandled error on %s %s", request.method, request.path)
if request.path.startswith("/api/"):
return web.json_response(
{"error": f"Internal server error: {exc}"},
status=500,
)
return web.Response(
text="<h1>500 Internal Server Error</h1>",
status=500,
content_type="text/html",
)
# ------------------------------------------------------------------
# Rate limiting state
# ------------------------------------------------------------------
_action_timestamps: Dict[str, float] = {}
_RATE_LIMIT_SECONDS = 10
# ------------------------------------------------------------------
# Authentication middleware
# ------------------------------------------------------------------
@web.middleware
async def auth_middleware(
request: web.Request,
handler,
) -> web.StreamResponse:
"""Authenticate all requests.
* ``/login`` and ``/favicon.ico`` are always allowed.
* All other routes require a valid session cookie.
* Unauthenticated HTML routes serve the login page.
* Unauthenticated ``/api/*`` returns 401.
* POST ``/api/actions/*`` enforces CSRF and rate limiting.
"""
# Login route is always open.
if request.path in ("/login", "/favicon.ico"):
return await handler(request)
# All requests require auth (no localhost bypass — behind reverse proxy).
# Check session cookie.
session_token = request.app.get("_session_token", "")
cookie = request.cookies.get("ayn_session", "")
authenticated = (
cookie
and session_token
and secrets.compare_digest(cookie, session_token)
)
if not authenticated:
if request.path.startswith("/api/"):
return web.json_response(
{"error": "Unauthorized. Please login."}, status=401,
)
# Serve login page for HTML routes.
return web.Response(
text=request.app["_login_html"], content_type="text/html",
)
# CSRF + rate-limiting for POST action endpoints.
if request.method == "POST" and request.path.startswith("/api/actions/"):
origin = request.headers.get("Origin", "")
if origin:
parsed = urlparse(origin)
origin_host = parsed.hostname or ""
host = request.headers.get("Host", "")
expected = host.split(":")[0] if host else ""
allowed = {expected, "localhost", "127.0.0.1", "::1"}
allowed.discard("")
if origin_host not in allowed:
return web.json_response(
{"error": "CSRF: Origin mismatch"}, status=403,
)
now = time.time()
last = _action_timestamps.get(request.path, 0)
if now - last < _RATE_LIMIT_SECONDS:
return web.json_response(
{"error": "Rate limited. Try again in a few seconds."},
status=429,
)
_action_timestamps[request.path] = now
return await handler(request)
# ------------------------------------------------------------------
# Dashboard server
# ------------------------------------------------------------------
class DashboardServer:
"""AYN Antivirus dashboard with username/password authentication."""
def __init__(self, config: Optional[Config] = None) -> None:
self.config = config or Config()
self.store = DashboardStore(self.config.dashboard_db_path)
self.collector = MetricsCollector(self.store)
self.app = web.Application(middlewares=[json_error_middleware, auth_middleware])
self._session_token: str = secrets.token_urlsafe(32)
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self._setup()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
def _setup(self) -> None:
"""Configure the aiohttp application."""
self.app["_session_token"] = self._session_token
self.app["_login_html"] = self._build_login_page()
self.app["store"] = self.store
self.app["collector"] = self.collector
self.app["config"] = self.config
# Quarantine vault (best-effort).
try:
from ayn_antivirus.quarantine.vault import QuarantineVault
self.app["vault"] = QuarantineVault(
quarantine_dir=self.config.quarantine_path,
key_file_path=QUARANTINE_ENCRYPTION_KEY_FILE,
)
except Exception as exc:
logger.warning("Quarantine vault not available: %s", exc)
# API routes (``/api/*``).
setup_routes(self.app)
# HTML routes.
self.app.router.add_get("/", self._serve_dashboard)
self.app.router.add_get("/dashboard", self._serve_dashboard)
self.app.router.add_get("/login", self._serve_login)
self.app.router.add_post("/login", self._handle_login)
# Lifecycle hooks.
self.app.on_startup.append(self._on_startup)
self.app.on_shutdown.append(self._on_shutdown)
# ------------------------------------------------------------------
# Request handlers
# ------------------------------------------------------------------
async def _serve_login(self, request: web.Request) -> web.Response:
"""``GET /login`` — render the login page."""
return web.Response(
text=self.app["_login_html"], content_type="text/html",
)
async def _serve_dashboard(self, request: web.Request) -> web.Response:
"""``GET /`` or ``GET /dashboard`` — render the SPA.
The middleware already enforces auth for non-localhost, so if we
reach here the client is authenticated (or local).
"""
html = get_dashboard_html()
return web.Response(text=html, content_type="text/html")
async def _handle_login(self, request: web.Request) -> web.Response:
"""``POST /login`` — validate username/password, set session cookie."""
try:
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "").strip()
except Exception:
return web.json_response({"error": "Invalid request"}, status=400)
if not username or not password:
return web.json_response(
{"error": "Username and password required"}, status=400,
)
valid_user = secrets.compare_digest(
username, self.config.dashboard_username,
)
valid_pass = secrets.compare_digest(
password, self.config.dashboard_password,
)
if not (valid_user and valid_pass):
self.store.log_activity(
f"Failed login attempt from {request.remote}: user={username}",
"WARNING",
"auth",
)
return web.json_response(
{"error": "Invalid username or password"}, status=401,
)
self.store.log_activity(
f"Successful login from {request.remote}: user={username}",
"INFO",
"auth",
)
response = web.json_response(
{"status": "ok", "message": "Welcome to AYN Antivirus"},
)
response.set_cookie(
"ayn_session",
self._session_token,
httponly=True,
max_age=86400,
samesite="Strict",
)
return response
# ------------------------------------------------------------------
# Login page
# ------------------------------------------------------------------
@staticmethod
def _build_login_page() -> str:
"""Return a polished HTML login form with username + password fields."""
return '''<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AYN Antivirus \u2014 Login</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0e17;color:#e2e8f0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
display:flex;justify-content:center;align-items:center;min-height:100vh;
background-image:radial-gradient(circle at 50% 50%,#111827 0%,#0a0e17 70%)}
.login-box{background:#111827;padding:2.5rem;border-radius:16px;border:1px solid #2a3444;
width:420px;max-width:90vw;box-shadow:0 25px 80px rgba(0,0,0,0.6)}
.logo{text-align:center;margin-bottom:2rem}
.logo .shield{font-size:3.5rem;display:block;margin-bottom:0.5rem}
.logo h1{font-size:1.8rem;background:linear-gradient(135deg,#3b82f6,#06b6d4);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:800}
.logo .subtitle{color:#6b7280;font-size:0.8rem;margin-top:0.3rem;letter-spacing:2px;text-transform:uppercase}
.field{margin-bottom:1.2rem}
.field label{display:block;font-size:0.75rem;color:#9ca3af;margin-bottom:0.4rem;
text-transform:uppercase;letter-spacing:1px;font-weight:600}
.field input{width:100%;padding:12px 16px;background:#0d1117;border:1px solid #2a3444;
color:#e2e8f0;border-radius:10px;font-size:15px;transition:all 0.2s;outline:none}
.field input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,0.15)}
.field input::placeholder{color:#4b5563}
.btn{width:100%;padding:13px;background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;
border:none;border-radius:10px;cursor:pointer;font-size:15px;font-weight:700;
transition:all 0.2s;margin-top:0.8rem;letter-spacing:0.5px}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(59,130,246,0.4)}
.btn:active{transform:translateY(0)}
.btn:disabled{opacity:0.5;cursor:not-allowed;transform:none}
.error{color:#fca5a5;font-size:0.85rem;text-align:center;margin-top:1rem;padding:10px 16px;
background:rgba(239,68,68,0.1);border-radius:8px;display:none;border:1px solid rgba(239,68,68,0.2)}
.footer{text-align:center;margin-top:2rem;padding-top:1.5rem;border-top:1px solid #1e293b}
.footer p{color:#4b5563;font-size:0.7rem;line-height:1.8}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid #fff;
border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;
vertical-align:middle;margin-right:8px}
@keyframes spin{to{transform:rotate(360deg)}}
</style></head>
<body>
<div class="login-box">
<div class="logo">
<span class="shield">\U0001f6e1\ufe0f</span>
<h1>AYN ANTIVIRUS</h1>
<div class="subtitle">Security Operations Dashboard</div>
</div>
<form id="loginForm" onsubmit="return doLogin()">
<div class="field">
<label>Username</label>
<input type="text" id="username" placeholder="Enter username" autocomplete="username" autofocus required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" placeholder="Enter password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn" id="loginBtn">\U0001f510 Sign In</button>
</form>
<div class="error" id="errMsg">Invalid credentials</div>
<div class="footer">
<p>AYN Antivirus v1.0.0 \u2014 Server Protection Suite<br>
Secure Access Portal</p>
</div>
</div>
<script>
async function doLogin(){
var btn=document.getElementById('loginBtn');
var err=document.getElementById('errMsg');
var user=document.getElementById('username').value.trim();
var pass=document.getElementById('password').value;
if(!user||!pass)return false;
err.style.display='none';
btn.disabled=true;
btn.innerHTML='<span class="spinner"></span>Signing in...';
try{
var r=await fetch('/login',{method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({username:user,password:pass})});
if(r.ok){window.location.href='/dashboard';}
else{var d=await r.json();err.textContent=d.error||'Invalid credentials';err.style.display='block';}
}catch(e){err.textContent='Connection failed. Check server.';err.style.display='block';}
btn.disabled=false;btn.innerHTML='\\U0001f510 Sign In';
return false;
}
document.querySelectorAll('input').forEach(function(i){i.addEventListener('input',function(){
document.getElementById('errMsg').style.display='none';
});});
</script>
</body></html>'''
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
async def _on_startup(self, app: web.Application) -> None:
await self.collector.start()
self.store.log_activity("Dashboard server started", "INFO", "server")
logger.info(
"Dashboard on http://%s:%d",
self.config.dashboard_host,
self.config.dashboard_port,
)
async def _on_shutdown(self, app: web.Application) -> None:
await self.collector.stop()
self.store.log_activity("Dashboard server stopped", "INFO", "server")
self.store.close()
# ------------------------------------------------------------------
# Blocking run
# ------------------------------------------------------------------
def run(self) -> None:
"""Run the dashboard server (blocking)."""
host = self.config.dashboard_host
port = self.config.dashboard_port
print(f"\n \U0001f6e1\ufe0f AYN Antivirus Dashboard")
print(f" \U0001f310 http://{host}:{port}")
print(f" \U0001f464 Username: {self.config.dashboard_username}")
print(f" \U0001f511 Password: {self.config.dashboard_password}")
print(f" Press Ctrl+C to stop\n")
web.run_app(self.app, host=host, port=port, print=None)
# ------------------------------------------------------------------
# Async start / stop (non-blocking)
# ------------------------------------------------------------------
async def start_async(self) -> None:
"""Start the server without blocking."""
self._runner = web.AppRunner(self.app)
await self._runner.setup()
self._site = web.TCPSite(
self._runner,
self.config.dashboard_host,
self.config.dashboard_port,
)
await self._site.start()
self.store.log_activity(
"Dashboard server started (async)", "INFO", "server",
)
async def stop_async(self) -> None:
"""Stop a server previously started with :meth:`start_async`."""
if self._site:
await self._site.stop()
if self._runner:
await self._runner.cleanup()
await self.collector.stop()
self.store.close()
# ------------------------------------------------------------------
# Convenience entry point
# ------------------------------------------------------------------
def run_dashboard(config: Optional[Config] = None) -> None:
"""Create a :class:`DashboardServer` and run it (blocking)."""
DashboardServer(config).run()
@@ -0,0 +1,386 @@
"""Persistent storage for dashboard metrics, threat logs, and scan history."""
from __future__ import annotations
import json
import os
import sqlite3
import threading
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from ayn_antivirus.constants import (
DASHBOARD_MAX_THREATS_DISPLAY,
DASHBOARD_METRIC_RETENTION_HOURS,
DASHBOARD_SCAN_HISTORY_DAYS,
DEFAULT_DASHBOARD_DB_PATH,
)
class DashboardStore:
"""SQLite-backed store for all dashboard data.
Parameters
----------
db_path:
Path to the SQLite database file. Created automatically if it
does not exist.
"""
def __init__(self, db_path: str = DEFAULT_DASHBOARD_DB_PATH) -> None:
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
self.db_path = db_path
self._lock = threading.RLock()
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA synchronous=NORMAL")
self._create_tables()
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def _create_tables(self) -> None:
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
cpu_percent REAL DEFAULT 0,
mem_percent REAL DEFAULT 0,
mem_used INTEGER DEFAULT 0,
mem_total INTEGER DEFAULT 0,
disk_usage_json TEXT DEFAULT '[]',
load_avg_json TEXT DEFAULT '[]',
net_connections INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS threat_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
file_path TEXT,
threat_name TEXT NOT NULL,
threat_type TEXT NOT NULL,
severity TEXT NOT NULL,
detector TEXT,
file_hash TEXT,
action_taken TEXT DEFAULT 'detected',
details TEXT
);
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
scan_type TEXT NOT NULL,
scan_path TEXT,
files_scanned INTEGER DEFAULT 0,
files_skipped INTEGER DEFAULT 0,
threats_found INTEGER DEFAULT 0,
duration_seconds REAL DEFAULT 0,
status TEXT DEFAULT 'completed'
);
CREATE TABLE IF NOT EXISTS signature_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
feed_name TEXT NOT NULL,
hashes_added INTEGER DEFAULT 0,
ips_added INTEGER DEFAULT 0,
domains_added INTEGER DEFAULT 0,
urls_added INTEGER DEFAULT 0,
status TEXT DEFAULT 'success',
details TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
level TEXT NOT NULL DEFAULT 'INFO',
source TEXT,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_ts ON metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_threats_ts ON threat_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_threats_severity ON threat_log(severity);
CREATE INDEX IF NOT EXISTS idx_scans_ts ON scan_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_sigs_ts ON signature_updates(timestamp);
CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity_log(timestamp);
""")
self.conn.commit()
# ------------------------------------------------------------------
# Metrics
# ------------------------------------------------------------------
def record_metric(
self,
cpu: float,
mem_pct: float,
mem_used: int,
mem_total: int,
disk_usage: list,
load_avg: list,
net_conns: int,
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO metrics "
"(cpu_percent, mem_percent, mem_used, mem_total, "
"disk_usage_json, load_avg_json, net_connections) "
"VALUES (?,?,?,?,?,?,?)",
(cpu, mem_pct, mem_used, mem_total,
json.dumps(disk_usage), json.dumps(load_avg), net_conns),
)
self.conn.commit()
def get_latest_metrics(self) -> Optional[Dict[str, Any]]:
with self._lock:
row = self.conn.execute(
"SELECT * FROM metrics ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return None
d = dict(row)
d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]"))
d["load_avg"] = json.loads(d.pop("load_avg_json", "[]"))
return d
def get_metrics_history(self, hours: int = 1) -> List[Dict[str, Any]]:
cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
rows = self.conn.execute(
"SELECT * FROM metrics WHERE timestamp >= ? ORDER BY timestamp",
(cutoff,),
).fetchall()
result: List[Dict[str, Any]] = []
for r in rows:
d = dict(r)
d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]"))
d["load_avg"] = json.loads(d.pop("load_avg_json", "[]"))
result.append(d)
return result
# ------------------------------------------------------------------
# Threats
# ------------------------------------------------------------------
def record_threat(
self,
file_path: str,
threat_name: str,
threat_type: str,
severity: str,
detector: str = "",
file_hash: str = "",
action: str = "detected",
details: str = "",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO threat_log "
"(file_path, threat_name, threat_type, severity, "
"detector, file_hash, action_taken, details) "
"VALUES (?,?,?,?,?,?,?,?)",
(file_path, threat_name, threat_type, severity,
detector, file_hash, action, details),
)
self.conn.commit()
def get_recent_threats(
self, limit: int = DASHBOARD_MAX_THREATS_DISPLAY,
) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM threat_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def get_threat_stats(self) -> Dict[str, Any]:
with self._lock:
total = self.conn.execute(
"SELECT COUNT(*) FROM threat_log"
).fetchone()[0]
by_severity: Dict[str, int] = {}
for row in self.conn.execute(
"SELECT severity, COUNT(*) as cnt FROM threat_log GROUP BY severity"
):
by_severity[row[0]] = row[1]
cutoff_24h = (datetime.utcnow() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
cutoff_7d = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
last_24h = self.conn.execute(
"SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?",
(cutoff_24h,),
).fetchone()[0]
last_7d = self.conn.execute(
"SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?",
(cutoff_7d,),
).fetchone()[0]
return {
"total": total,
"by_severity": by_severity,
"last_24h": last_24h,
"last_7d": last_7d,
}
# ------------------------------------------------------------------
# Scans
# ------------------------------------------------------------------
def record_scan(
self,
scan_type: str,
scan_path: str,
files_scanned: int,
files_skipped: int,
threats_found: int,
duration: float,
status: str = "completed",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO scan_history "
"(scan_type, scan_path, files_scanned, files_skipped, "
"threats_found, duration_seconds, status) "
"VALUES (?,?,?,?,?,?,?)",
(scan_type, scan_path, files_scanned, files_skipped,
threats_found, duration, status),
)
self.conn.commit()
def get_recent_scans(self, limit: int = 30) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM scan_history ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def get_scan_chart_data(
self, days: int = DASHBOARD_SCAN_HISTORY_DAYS,
) -> List[Dict[str, Any]]:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
rows = self.conn.execute(
"SELECT DATE(timestamp) as day, "
"COUNT(*) as scans, "
"SUM(threats_found) as threats, "
"SUM(files_scanned) as files "
"FROM scan_history WHERE timestamp >= ? "
"GROUP BY DATE(timestamp) ORDER BY day",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Signature Updates
# ------------------------------------------------------------------
def record_sig_update(
self,
feed_name: str,
hashes: int = 0,
ips: int = 0,
domains: int = 0,
urls: int = 0,
status: str = "success",
details: str = "",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO signature_updates "
"(feed_name, hashes_added, ips_added, domains_added, "
"urls_added, status, details) "
"VALUES (?,?,?,?,?,?,?)",
(feed_name, hashes, ips, domains, urls, status, details),
)
self.conn.commit()
def get_recent_sig_updates(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM signature_updates ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def get_sig_stats(self) -> Dict[str, Any]:
"""Return signature stats from the actual signatures database."""
result = {
"total_hashes": 0,
"total_ips": 0,
"total_domains": 0,
"total_urls": 0,
"last_update": None,
}
# Try to read live counts from the signatures DB
sig_db_path = self.db_path.replace("dashboard.db", "signatures.db")
try:
import sqlite3 as _sql
sdb = _sql.connect(sig_db_path)
sdb.row_factory = _sql.Row
for tbl, key in [("threats", "total_hashes"), ("ioc_ips", "total_ips"),
("ioc_domains", "total_domains"), ("ioc_urls", "total_urls")]:
try:
result[key] = sdb.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
except Exception:
pass
try:
ts = sdb.execute("SELECT MAX(added_date) FROM threats").fetchone()[0]
result["last_update"] = ts
except Exception:
pass
sdb.close()
except Exception:
# Fallback to dashboard update log
with self._lock:
row = self.conn.execute(
"SELECT SUM(hashes_added), SUM(ips_added), "
"SUM(domains_added), SUM(urls_added) FROM signature_updates"
).fetchone()
result["total_hashes"] = row[0] or 0
result["total_ips"] = row[1] or 0
result["total_domains"] = row[2] or 0
result["total_urls"] = row[3] or 0
lu = self.conn.execute(
"SELECT MAX(timestamp) FROM signature_updates"
).fetchone()[0]
result["last_update"] = lu
return result
# ------------------------------------------------------------------
# Activity Log
# ------------------------------------------------------------------
def log_activity(
self,
message: str,
level: str = "INFO",
source: str = "system",
) -> None:
with self._lock:
self.conn.execute(
"INSERT INTO activity_log (level, source, message) VALUES (?,?,?)",
(level, source, message),
)
self.conn.commit()
def get_recent_logs(self, limit: int = 20) -> List[Dict[str, Any]]:
with self._lock:
rows = self.conn.execute(
"SELECT * FROM activity_log ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
def cleanup_old_metrics(
self, hours: int = DASHBOARD_METRIC_RETENTION_HOURS,
) -> None:
cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
with self._lock:
self.conn.execute("DELETE FROM metrics WHERE timestamp < ?", (cutoff,))
self.conn.commit()
def close(self) -> None:
self.conn.close()
@@ -0,0 +1,910 @@
"""AYN Antivirus Dashboard — HTML Template.
Single-page application with embedded CSS and JavaScript.
All data is fetched from the ``/api/*`` endpoints.
"""
from __future__ import annotations
def get_dashboard_html() -> str:
"""Return the complete HTML dashboard as a string."""
return _HTML
_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AYN Antivirus Security Dashboard</title>
<style>
:root{--bg:#0a0e17;--surface:#111827;--surface2:#1a2332;--surface3:#1f2b3d;
--border:#2a3444;--text:#e2e8f0;--text-dim:#8892a4;--accent:#3b82f6;
--green:#10b981;--red:#ef4444;--orange:#f59e0b;--yellow:#eab308;
--purple:#8b5cf6;--cyan:#06b6d4;--radius:8px;--shadow:0 2px 8px rgba(0,0,0,.3)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,-apple-system,sans-serif;font-size:14px;min-height:100vh}
a{color:var(--accent);text-decoration:none}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:var(--surface)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* Header */
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.header-left{display:flex;align-items:center;gap:16px}
.logo{font-size:1.3rem;font-weight:800;letter-spacing:.05em}
.logo span{color:var(--accent)}
.header-meta{display:flex;gap:20px;font-size:.82rem;color:var(--text-dim)}
.header-meta b{color:var(--text);font-weight:600}
.pulse{display:inline-block;width:8px;height:8px;background:var(--green);border-radius:50%;margin-right:6px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* Navigation */
.nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;gap:0;overflow-x:auto}
.nav-tab{padding:12px 20px;cursor:pointer;color:var(--text-dim);font-weight:600;font-size:.85rem;border-bottom:2px solid transparent;transition:all .2s;white-space:nowrap;user-select:none}
.nav-tab:hover{color:var(--text);background:var(--surface2)}
.nav-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* Layout */
.content{padding:20px 24px;max-width:1440px;margin:0 auto}
.tab-panel{display:none;animation:fadeIn .25s}
.tab-panel.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.grid{display:grid;gap:16px}
.g2{grid-template-columns:repeat(2,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
.g4{grid-template-columns:repeat(4,1fr)}
.g6{grid-template-columns:repeat(6,1fr)}
@media(max-width:900px){.g2,.g3,.g4,.g6{grid-template-columns:1fr}}
@media(min-width:901px) and (max-width:1200px){.g4{grid-template-columns:repeat(2,1fr)}.g6{grid-template-columns:repeat(3,1fr)}}
.section{margin-bottom:24px}
.section-title{font-size:1rem;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.section-title .icon{font-size:1.1rem}
/* Cards */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;transition:border-color .2s,box-shadow .2s}
.card:hover{border-color:var(--accent);box-shadow:0 0 12px rgba(59,130,246,.1)}
.card-label{font-size:.75rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.card-value{font-size:1.6rem;font-weight:700}
.card-sub{font-size:.78rem;color:var(--text-dim);margin-top:4px}
.card-green .card-value{color:var(--green)}
.card-red .card-value{color:var(--red)}
.card-orange .card-value{color:var(--orange)}
.card-yellow .card-value{color:var(--yellow)}
.card-accent .card-value{color:var(--accent)}
.card-purple .card-value{color:var(--purple)}
/* Gauge (SVG circular) */
.gauge-wrap{display:flex;flex-direction:column;align-items:center;padding:12px}
.gauge{position:relative;width:110px;height:110px}
.gauge svg{transform:rotate(-90deg)}
.gauge-bg{fill:none;stroke:var(--border);stroke-width:10}
.gauge-fill{fill:none;stroke-width:10;stroke-linecap:round;transition:stroke-dashoffset .8s ease}
.gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.3rem;font-weight:700}
.gauge-label{margin-top:8px;font-size:.8rem;color:var(--text-dim);font-weight:600}
/* Badges */
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em}
.badge-critical{background:rgba(239,68,68,.15);color:var(--red);border:1px solid var(--red)}
.badge-high{background:rgba(245,158,11,.15);color:var(--orange);border:1px solid var(--orange)}
.badge-medium{background:rgba(234,179,8,.15);color:var(--yellow);border:1px solid var(--yellow)}
.badge-low{background:rgba(16,185,129,.15);color:var(--green);border:1px solid var(--green)}
.badge-success{background:rgba(16,185,129,.15);color:var(--green);border:1px solid var(--green)}
.badge-error{background:rgba(239,68,68,.15);color:var(--red);border:1px solid var(--red)}
.badge-running{background:rgba(59,130,246,.15);color:var(--accent);border:1px solid var(--accent)}
.badge-info{background:rgba(59,130,246,.15);color:var(--accent);border:1px solid var(--accent)}
.badge-warning{background:rgba(245,158,11,.15);color:var(--orange);border:1px solid var(--orange)}
/* Tables */
.tbl-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
table{width:100%;border-collapse:collapse}
th{background:var(--surface2);color:var(--text-dim);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;padding:10px 12px;text-align:left;position:sticky;top:0}
td{padding:9px 12px;border-top:1px solid var(--border);font-size:.84rem;vertical-align:middle}
tr:hover td{background:rgba(59,130,246,.04)}
.mono{font-family:'Cascadia Code','Fira Code',monospace;font-size:.78rem}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.empty-row td{text-align:center;padding:32px;color:var(--text-dim);font-size:.95rem}
/* Bar Chart (CSS) */
.bar-chart{display:flex;align-items:flex-end;gap:4px;height:120px;padding:8px 0}
.bar-col{display:flex;flex-direction:column;align-items:center;flex:1;min-width:0}
.bar{width:100%;min-height:2px;border-radius:3px 3px 0 0;transition:height .4s;position:relative;cursor:default}
.bar:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:3px 8px;border-radius:4px;font-size:.7rem;white-space:nowrap;z-index:5}
.bar-label{font-size:.6rem;color:var(--text-dim);margin-top:4px;writing-mode:vertical-lr;transform:rotate(180deg);max-height:40px;overflow:hidden}
.bar-threats{background:var(--red)}
.bar-scans{background:var(--accent)}
/* Disk bars */
.disk-row{display:flex;align-items:center;gap:12px;padding:6px 0}
.disk-mount{width:100px;font-size:.8rem;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.disk-bar-outer{flex:1;height:14px;background:var(--surface2);border-radius:7px;overflow:hidden}
.disk-bar-inner{height:100%;border-radius:7px;transition:width .6s}
.disk-pct{width:50px;text-align:right;font-size:.8rem;font-weight:600}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:.82rem;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap}
.btn:hover{border-color:var(--accent);background:var(--surface3)}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn-primary:hover{background:#2563eb}
.btn-sm{padding:5px 10px;font-size:.76rem}
.btn:disabled{opacity:.5;cursor:not-allowed}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
/* Filter bar */
.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
.filters select,.filters input{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:7px 10px;border-radius:6px;font-size:.82rem}
.filters select:focus,.filters input:focus{outline:none;border-color:var(--accent)}
.filters input{min-width:200px}
/* Sub-tabs */
.sub-tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid var(--border)}
.sub-tab{padding:8px 16px;cursor:pointer;color:var(--text-dim);font-size:.82rem;font-weight:600;border-bottom:2px solid transparent;transition:all .2s}
.sub-tab:hover{color:var(--text)}
.sub-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
/* Pagination */
.pager{display:flex;align-items:center;justify-content:center;gap:12px;padding:12px;font-size:.84rem;color:var(--text-dim)}
/* Logs */
.log-view{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:8px;max-height:500px;overflow-y:auto;font-family:'Cascadia Code','Fira Code',monospace;font-size:.78rem;line-height:1.7}
.log-line{padding:2px 4px;display:flex;gap:10px;border-bottom:1px solid rgba(42,52,68,.4)}
.log-ts{color:var(--text-dim);min-width:140px}
.log-src{color:var(--purple);min-width:80px}
.log-msg{flex:1;word-break:break-all}
/* Toast */
.toast-area{position:fixed;top:70px;right:20px;z-index:200;display:flex;flex-direction:column;gap:8px}
.toast{padding:10px 18px;border-radius:6px;font-size:.84rem;font-weight:600;animation:slideIn .3s;box-shadow:var(--shadow)}
.toast-success{background:#065f46;color:#a7f3d0;border:1px solid var(--green)}
.toast-error{background:#7f1d1d;color:#fca5a5;border:1px solid var(--red)}
.toast-info{background:#1e3a5f;color:#93c5fd;border:1px solid var(--accent)}
@keyframes slideIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header">
<div class="header-left">
<div class="logo"> <span>AYN</span> ANTIVIRUS</div>
<div style="font-size:.75rem;color:var(--text-dim)">Security Dashboard</div>
</div>
<div class="header-meta">
<div><span class="pulse"></span><b id="hd-host"></b></div>
<div>Up <b id="hd-uptime"></b></div>
<div id="hd-time"></div>
</div>
</div>
<!-- NAV -->
<div class="nav" id="nav">
<div class="nav-tab active" data-tab="overview">📊 Overview</div>
<div class="nav-tab" data-tab="threats">🛡 Threats</div>
<div class="nav-tab" data-tab="scans">🔍 Scans</div>
<div class="nav-tab" data-tab="definitions">📚 Definitions</div>
<div class="nav-tab" data-tab="containers">🐳 Containers</div>
<div class="nav-tab" data-tab="quarantine">🔒 Quarantine</div>
<div class="nav-tab" data-tab="logs">📋 Logs</div>
</div>
<!-- CONTENT -->
<div class="content">
<!-- TAB: OVERVIEW -->
<div class="tab-panel active" id="panel-overview">
<!-- Status cards -->
<div class="section">
<div class="grid g4" id="status-cards">
<div class="card card-green"><div class="card-label">Protection</div><div class="card-value" id="ov-prot">Active</div><div class="card-sub" id="ov-prot-sub">AI-powered analysis</div></div>
<div class="card card-accent"><div class="card-label">Last Scan</div><div class="card-value" id="ov-scan"></div><div class="card-sub" id="ov-scan-sub"></div></div>
<div class="card card-purple"><div class="card-label">Signatures</div><div class="card-value" id="ov-sigs"></div><div class="card-sub" id="ov-sigs-sub"></div></div>
<div class="card"><div class="card-label">Quarantine</div><div class="card-value" id="ov-quar">0</div><div class="card-sub">Isolated items</div></div>
</div>
</div>
<!-- CPU Per-Core -->
<div class="section">
<div class="section-title"><span class="icon">🧮</span> CPU Per Core <span id="cpu-summary" style="font-size:.8rem;color:var(--text-dim);margin-left:8px"></span></div>
<div class="card" style="padding:12px">
<canvas id="cpu-canvas" height="140" style="width:100%;display:block"></canvas>
</div>
</div>
<!-- Memory Breakdown -->
<div class="section">
<div class="section-title"><span class="icon">🧠</span> Memory Usage <span id="mem-summary" style="font-size:.8rem;color:var(--text-dim);margin-left:8px"></span></div>
<div class="grid g2">
<div class="card" style="padding:12px">
<canvas id="mem-canvas" height="160" style="width:100%;display:block"></canvas>
</div>
<div class="card" style="padding:12px">
<div class="card-label">Memory Breakdown</div>
<div id="mem-bars" style="margin-top:8px"></div>
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:8px">
<div class="card-label">Swap</div>
<div id="swap-bar"></div>
</div>
</div>
</div>
</div>
<!-- Load + Network + Processes -->
<div class="section">
<div class="grid g3">
<div class="card"><div class="card-label">Load Average</div><div class="card-value" id="ov-load" style="font-size:1.2rem"></div><div class="card-sub">1 / 5 / 15 min</div></div>
<div class="card"><div class="card-label">Network Connections</div><div class="card-value" id="ov-netconn"></div><div class="card-sub">Active inet sockets</div></div>
<div class="card"><div class="card-label">CPU Frequency</div><div class="card-value" id="ov-freq" style="font-size:1.2rem"></div><div class="card-sub">Current MHz</div></div>
</div>
</div>
<!-- Top Processes -->
<div class="section">
<div class="section-title"><span class="icon"></span> Top Processes</div>
<div class="tbl-wrap">
<table><thead><tr><th>PID</th><th>Process</th><th>CPU %</th><th>RAM %</th></tr></thead><tbody id="proc-tbody"><tr class="empty-row"><td colspan="4">Loading</td></tr></tbody></table>
</div>
</div>
<!-- Disk -->
<div class="section">
<div class="section-title"><span class="icon">💾</span> Disk Usage</div>
<div class="card" id="disk-area"><div style="color:var(--text-dim);padding:8px">Loading</div></div>
</div>
<!-- Threat summary -->
<div class="section">
<div class="section-title"><span class="icon"></span> Threat Summary</div>
<div class="grid g4">
<div class="card card-red"><div class="card-label">Critical</div><div class="card-value" id="ov-tc">0</div></div>
<div class="card card-orange"><div class="card-label">High</div><div class="card-value" id="ov-th">0</div></div>
<div class="card card-yellow"><div class="card-label">Medium</div><div class="card-value" id="ov-tm">0</div></div>
<div class="card card-green"><div class="card-label">Low</div><div class="card-value" id="ov-tl">0</div></div>
</div>
</div>
<!-- Scan Activity Chart -->
<div class="section">
<div class="section-title"><span class="icon">📈</span> Scan Activity (14 days)</div>
<div class="card" style="padding:12px">
<canvas id="scan-canvas" height="160" style="width:100%;display:block"></canvas>
<div style="display:flex;gap:16px;justify-content:center;margin-top:8px;font-size:.75rem;color:var(--text-dim)">
<span>🔵 Scans</span><span>🔴 Threats Found</span>
</div>
</div>
</div>
</div>
<!-- TAB: THREATS -->
<div class="tab-panel" id="panel-threats">
<div class="filters">
<select id="f-severity"><option value="">All Severities</option><option value="CRITICAL">Critical</option><option value="HIGH">High</option><option value="MEDIUM">Medium</option><option value="LOW">Low</option></select>
<select id="f-type"><option value="">All Types</option><option value="MALWARE">Malware</option><option value="MINER">Miner</option><option value="SPYWARE">Spyware</option><option value="VIRUS">Virus</option><option value="ROOTKIT">Rootkit</option></select>
<input type="text" id="f-search" placeholder="Search threats…">
</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>File Path</th><th>Threat</th><th>Type</th><th>Severity</th><th>Detector</th><th>AI Verdict</th><th>Status</th><th>Actions</th></tr></thead><tbody id="threat-tbody"><tr class="empty-row"><td colspan="9">Loading</td></tr></tbody></table>
</div>
<div class="pager"><button class="btn btn-sm" id="threat-prev"> Prev</button><span id="threat-page">Page 1</span><button class="btn btn-sm" id="threat-next">Next </button></div>
</div>
<!-- TAB: SCANS -->
<div class="tab-panel" id="panel-scans">
<div class="btn-row">
<button class="btn btn-primary" id="btn-quick-scan" onclick="doAction('quick-scan',this)"> Run Quick Scan</button>
<button class="btn" id="btn-full-scan" onclick="doAction('full-scan',this)">🔍 Run Full Scan</button>
</div>
<div class="section">
<div class="section-title"><span class="icon">📈</span> Scan History (30 days)</div>
<div class="card" style="padding:12px"><canvas id="scan-chart-canvas" height="160" style="width:100%;display:block"></canvas><div style="display:flex;gap:16px;justify-content:center;margin-top:8px;font-size:.75rem;color:var(--text-dim)"><span>🔵 Scans</span><span>🔴 Threats</span></div></div>
</div>
<div class="section">
<div class="section-title"><span class="icon">📋</span> Recent Scans</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>Type</th><th>Path</th><th>Files</th><th>Threats</th><th>Duration</th><th>Status</th></tr></thead><tbody id="scan-tbody"><tr class="empty-row"><td colspan="7">Loading</td></tr></tbody></table>
</div>
</div>
</div>
<!-- TAB: DEFINITIONS -->
<div class="tab-panel" id="panel-definitions">
<div class="grid g4 section">
<div class="card card-purple"><div class="card-label">Hashes</div><div class="card-value" id="def-hashes">0</div></div>
<div class="card card-accent"><div class="card-label">Malicious IPs</div><div class="card-value" id="def-ips">0</div></div>
<div class="card card-orange"><div class="card-label">Domains</div><div class="card-value" id="def-domains">0</div></div>
<div class="card card-red"><div class="card-label">URLs</div><div class="card-value" id="def-urls">0</div></div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="doAction('update-sigs',this)">🔄 Update All Feeds</button>
<button class="btn btn-sm" onclick="doFeedUpdate('malwarebazaar',this)">MalwareBazaar</button>
<button class="btn btn-sm" onclick="doFeedUpdate('threatfox',this)">ThreatFox</button>
<button class="btn btn-sm" onclick="doFeedUpdate('urlhaus',this)">URLhaus</button>
<button class="btn btn-sm" onclick="doFeedUpdate('feodotracker',this)">FeodoTracker</button>
<button class="btn btn-sm" onclick="doFeedUpdate('emergingthreats',this)">EmergingThreats</button>
</div>
<div class="sub-tabs" id="def-subtabs">
<div class="sub-tab active" data-def="all">All</div>
<div class="sub-tab" data-def="hash">Hashes</div>
<div class="sub-tab" data-def="ip">IPs</div>
<div class="sub-tab" data-def="domain">Domains</div>
<div class="sub-tab" data-def="url">URLs</div>
</div>
<div class="filters"><input type="text" id="def-search" placeholder="Search definitions…" style="flex:1;max-width:400px"></div>
<div class="tbl-wrap"><table><thead id="def-thead"></thead><tbody id="def-tbody"><tr class="empty-row"><td colspan="6">Loading</td></tr></tbody></table></div>
<div class="pager"><button class="btn btn-sm" id="def-prev" onclick="defPage(-1)"> Prev</button><span id="def-page-info">Page 1</span><button class="btn btn-sm" id="def-next" onclick="defPage(1)">Next </button></div>
<div class="section" style="margin-top:20px">
<div class="section-title"><span class="icon">🔄</span> Recent Updates</div>
<div class="tbl-wrap"><table><thead><tr><th>Time</th><th>Feed</th><th>Hashes</th><th>IPs</th><th>Domains</th><th>URLs</th><th>Status</th></tr></thead><tbody id="sigup-tbody"></tbody></table></div>
</div>
</div>
<!-- TAB: CONTAINERS -->
<div class="tab-panel" id="panel-containers">
<div class="btn-row">
<button class="btn btn-primary" id="btn-scan-containers" onclick="doAction('scan-containers',this)">🐳 Scan All Containers</button>
</div>
<div class="grid g3 section">
<div class="card card-accent"><div class="card-label">Containers Found</div><div class="card-value" id="ct-count">0</div></div>
<div class="card card-green"><div class="card-label">Available Runtimes</div><div class="card-value" id="ct-runtimes" style="font-size:1rem"></div></div>
<div class="card card-red"><div class="card-label">Container Threats</div><div class="card-value" id="ct-threats">0</div></div>
</div>
<div class="section">
<div class="section-title"><span class="icon">📦</span> Discovered Containers</div>
<div class="tbl-wrap">
<table><thead><tr><th>ID</th><th>Name</th><th>Image</th><th>Runtime</th><th>Status</th><th>IP</th><th>Ports</th><th>Action</th></tr></thead>
<tbody id="ct-tbody"><tr class="empty-row"><td colspan="8">Loading</td></tr></tbody></table>
</div>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Container Threats</div>
<div class="tbl-wrap">
<table><thead><tr><th>Time</th><th>Container</th><th>Threat</th><th>Type</th><th>Severity</th><th>Details</th></tr></thead>
<tbody id="ct-threat-tbody"><tr class="empty-row"><td colspan="6">No container threats </td></tr></tbody></table>
</div>
</div>
</div>
<!-- TAB: QUARANTINE -->
<div class="tab-panel" id="panel-quarantine">
<div class="grid g2 section">
<div class="card"><div class="card-label">Total Quarantined</div><div class="card-value" id="q-count">0</div></div>
<div class="card"><div class="card-label">Vault Size</div><div class="card-value" id="q-size">0 B</div></div>
</div>
<div class="tbl-wrap"><table><thead><tr><th>ID</th><th>Original Path</th><th>Threat</th><th>Date</th><th>Size</th></tr></thead><tbody id="quar-tbody"><tr class="empty-row"><td colspan="5">Vault is empty </td></tr></tbody></table></div>
</div>
<!-- TAB: LOGS -->
<div class="tab-panel" id="panel-logs">
<div class="btn-row"><button class="btn btn-sm" onclick="loadLogs()">🔄 Refresh</button></div>
<div class="log-view" id="log-view"><div style="color:var(--text-dim)">Loading</div></div>
</div>
</div><!-- /content -->
<!-- Toast area -->
<div class="toast-area" id="toast-area"></div>
<script>
/* State */
let S={threats:[],threatPage:1,threatPerPage:25,defType:'all',defPage:1,defPerPage:50,defSearch:''};
const $=id=>document.getElementById(id);
const Q=(s,el)=>(el||document).querySelectorAll(s);
/* Helpers */
function fmt(n){return n==null?'':Number(n).toLocaleString()}
function fmtBytes(b){if(!b)return '0 B';const u=['B','KB','MB','GB','TB'];let i=0;let v=b;while(v>=1024&&i<u.length-1){v/=1024;i++;}return v.toFixed(i?1:0)+' '+u[i];}
function fmtDur(s){if(!s||s<0)return '0s';s=Math.round(s);if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m '+s%60+'s';return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m';}
function ago(ts){if(!ts)return '';const d=new Date(ts+'Z');const s=Math.floor((Date.now()-d)/1000);if(s<60)return s+'s ago';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago';}
function sevBadge(s){const c=esc((s||'').toUpperCase());const cl=c.toLowerCase().replace(/[^a-z]/g,'');return `<span class="badge badge-${cl}">${c}</span>`;}
function statusBadge(s){const m={completed:'success',success:'success',running:'running',failed:'error',error:'error'};const safe=esc(s||'');const cl=(m[s]||'info').replace(/[^a-z]/g,'');return `<span class="badge badge-${cl}">${safe}</span>`;}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;}
function trunc(s,n){s=s||'';return s.length>n?s.slice(0,n)+'':s;}
function setGauge(id,pct,color){const g=$(id);if(!g)return;const c=g.querySelector('.gauge-fill');const t=g.querySelector('.gauge-text');const off=314-(314*Math.min(pct,100)/100);c.style.strokeDashoffset=off;if(color)c.style.stroke=color;t.textContent=Math.round(pct)+'%';}
function gaugeColor(p){return p>90?'var(--red)':p>70?'var(--orange)':p>50?'var(--yellow)':'var(--green)';}
/* Toast */
function toast(msg,type='info'){const t=document.createElement('div');t.className='toast toast-'+type;t.textContent=msg;$('toast-area').appendChild(t);setTimeout(()=>t.remove(),4000);}
/* API */
async function api(path){try{const r=await fetch(path);if(!r.ok)throw new Error(r.statusText);return await r.json();}catch(e){console.error('API error:',path,e);return null;}}
/* Tab switching */
Q('.nav-tab').forEach(t=>t.addEventListener('click',()=>{
Q('.nav-tab').forEach(x=>x.classList.remove('active'));
Q('.tab-panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
$('panel-'+t.dataset.tab).classList.add('active');
if(t.dataset.tab==='threats')loadThreats();
if(t.dataset.tab==='scans')loadScans();
if(t.dataset.tab==='definitions')loadDefs();
if(t.dataset.tab==='containers')loadContainers();
if(t.dataset.tab==='quarantine')loadQuarantine();
if(t.dataset.tab==='logs')loadLogs();
}));
/* OVERVIEW */
/* Canvas Chart Helpers */
let _cpuHistory=[];const _CPU_HIST_MAX=60;
let _memHistory=[];const _MEM_HIST_MAX=60;
function drawLineChart(canvasId,datasets,opts={}){
const cv=$(canvasId);if(!cv)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=(opts.height||rect.height)*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=opts.height||rect.height;
const pad={t:10,r:10,b:24,l:42};
const cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
// Background
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
// Grid
const gridLines=opts.gridLines||5;
const maxVal=opts.maxVal||Math.max(...datasets.flatMap(d=>d.data),1);
ctx.strokeStyle='#1e293b';ctx.lineWidth=1;ctx.font='10px system-ui';ctx.fillStyle='#6b7280';
for(let i=0;i<=gridLines;i++){
const y=pad.t+ch-(ch*i/gridLines);
ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(pad.l+cw,y);ctx.stroke();
const v=((maxVal*i/gridLines)).toFixed(opts.decimals||0);
ctx.fillText(v+(opts.unit||''),2,y+3);
}
// Data lines
datasets.forEach(ds=>{
if(!ds.data.length)return;
const n=ds.data.length;
ctx.beginPath();ctx.strokeStyle=ds.color;ctx.lineWidth=ds.lineWidth||2;
ds.data.forEach((v,i)=>{
const x=pad.l+(cw*i/(n-1||1));
const y=pad.t+ch-ch*(Math.min(v,maxVal)/maxVal);
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
});
ctx.stroke();
// Fill
if(ds.fill){
const n2=ds.data.length;
ctx.lineTo(pad.l+cw,pad.t+ch);ctx.lineTo(pad.l,pad.t+ch);ctx.closePath();
ctx.fillStyle=ds.fill;ctx.fill();
}
});
// Labels
if(opts.labels&&opts.labels.length){
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';ctx.textAlign='center';
const n=opts.labels.length;
opts.labels.forEach((l,i)=>{
if(i%Math.ceil(n/8)!==0&&i!==n-1)return;
const x=pad.l+(cw*i/(n-1||1));
ctx.fillText(l,x,H-2);
});
}
}
function drawBarChart(canvasId,data,opts={}){
const cv=$(canvasId);if(!cv||!data.length)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=(opts.height||rect.height)*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=opts.height||rect.height;
const pad={t:10,r:10,b:28,l:42};
const cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
const maxVal=opts.maxVal||Math.max(...data.flatMap(d=>[(d.scans||0),(d.threats||0)]),1);
// Grid
ctx.strokeStyle='#1e293b';ctx.lineWidth=1;ctx.font='10px system-ui';ctx.fillStyle='#6b7280';
for(let i=0;i<=4;i++){
const y=pad.t+ch-(ch*i/4);
ctx.beginPath();ctx.moveTo(pad.l,y);ctx.lineTo(pad.l+cw,y);ctx.stroke();
ctx.fillText(Math.round(maxVal*i/4),2,y+3);
}
const n=data.length;const bw=Math.max((cw/n)*0.35,2);const gap=cw/n;
data.forEach((d,i)=>{
const x=pad.l+gap*i+gap*0.15;
const sh=ch*(d.scans||0)/maxVal;
const th=ch*(d.threats||0)/maxVal;
// Scans bar
ctx.fillStyle='#3b82f6';ctx.fillRect(x,pad.t+ch-sh,bw,sh);
// Threats bar
ctx.fillStyle='#ef4444';ctx.fillRect(x+bw+1,pad.t+ch-th,bw,th);
// Label
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';ctx.textAlign='center';
const day=(d.day||'').slice(5);
ctx.fillText(day,x+bw,H-4);
});
}
function drawCoreChart(canvasId,cores){
const cv=$(canvasId);if(!cv||!cores.length)return;
const dpr=window.devicePixelRatio||1;
const rect=cv.getBoundingClientRect();
cv.width=rect.width*dpr;cv.height=140*dpr;
const ctx=cv.getContext('2d');ctx.scale(dpr,dpr);
const W=rect.width,H=140;
const pad={t:8,r:8,b:20,l:8};
const n=cores.length;const gap=4;
const bw=Math.min((W-pad.l-pad.r-(n-1)*gap)/n,60);
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
cores.forEach((pct,i)=>{
const x=pad.l+i*(bw+gap);
const barH=(H-pad.t-pad.b)*(pct/100);
const c=pct>90?'#ef4444':pct>70?'#f59e0b':pct>50?'#eab308':'#3b82f6';
// Background
ctx.fillStyle='#1e293b';ctx.fillRect(x,pad.t,bw,H-pad.t-pad.b);
// Bar
ctx.fillStyle=c;ctx.fillRect(x,H-pad.b-barH,bw,barH);
// Label
ctx.fillStyle='#e2e8f0';ctx.font='bold 10px system-ui';ctx.textAlign='center';
ctx.fillText(Math.round(pct)+'%',x+bw/2,H-pad.b-barH-4>pad.t?H-pad.b-barH-4:pad.t+12);
ctx.fillStyle='#6b7280';ctx.font='9px system-ui';
ctx.fillText('C'+i,x+bw/2,H-4);
});
}
function renderMemBars(h){
const total=h.mem_total||1;
const used=h.mem_used||0;
const cached=h.mem_cached||0;
const buffers=h.mem_buffers||0;
const avail=h.mem_available||0;
const app=used-cached-buffers;
const items=[
{label:'App/Used',val:Math.max(app,0),color:'var(--purple)'},
{label:'Cached',val:cached,color:'var(--cyan)'},
{label:'Buffers',val:buffers,color:'var(--accent)'},
{label:'Available',val:avail,color:'var(--green)'},
];
$('mem-bars').innerHTML=items.map(it=>{
const pct=(it.val/total*100).toFixed(1);
return `<div class="disk-row"><div class="disk-mount" style="width:70px"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${it.color};margin-right:4px"></span>${it.label}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${pct}%;background:${it.color}"></div></div><div class="disk-pct">${fmtBytes(it.val)}</div></div>`;
}).join('');
// Swap
const spct=h.swap_total?(h.swap_used/h.swap_total*100).toFixed(1):0;
$('swap-bar').innerHTML=h.swap_total?`<div class="disk-row"><div class="disk-mount" style="width:70px">${fmtBytes(h.swap_used)}/${fmtBytes(h.swap_total)}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${spct}%;background:var(--orange)"></div></div><div class="disk-pct">${spct}%</div></div>`:'<div style="color:var(--text-dim);font-size:.8rem">No swap</div>';
}
async function loadOverview(){
const [st,h,ts,ch]=await Promise.all([api('/api/status'),api('/api/health'),api('/api/threat-stats'),api('/api/scan-chart?days=14')]);
if(st){
$('hd-host').textContent=st.hostname||'';
$('hd-uptime').textContent=fmtDur(st.uptime_seconds);
$('hd-time').textContent=st.server_time||'';
const ls=st.last_scan;
$('ov-scan').textContent=ls?ago(ls.timestamp):'Never';
$('ov-scan-sub').textContent=ls?`${fmt(ls.files_scanned)} files, ${ls.threats_found} threats`:'No scans yet';
const sig=st.signatures||{};
$('ov-sigs').textContent=fmt((sig.total_hashes||0)+(sig.total_ips||0)+(sig.total_domains||0)+(sig.total_urls||0));
$('ov-sigs-sub').textContent=sig.last_update?'Updated '+ago(sig.last_update):'Not updated';
$('ov-quar').textContent=fmt(st.quarantine_count);
}
if(h){
// CPU per-core chart
const cores=h.cpu_per_core||[];
if(cores.length){
drawCoreChart('cpu-canvas',cores);
$('cpu-summary').textContent=`${cores.length} cores @ ${h.cpu_freq_mhz||'?'} MHz avg ${Math.round(h.cpu_percent)}%`;
}
// CPU history
_cpuHistory.push(h.cpu_percent||0);if(_cpuHistory.length>_CPU_HIST_MAX)_cpuHistory.shift();
// Memory
_memHistory.push(h.mem_percent||0);if(_memHistory.length>_MEM_HIST_MAX)_memHistory.shift();
drawLineChart('mem-canvas',[
{data:_memHistory,color:'#8b5cf6',fill:'rgba(139,92,246,0.1)',lineWidth:2},
],{maxVal:100,unit:'%',height:160,gridLines:4});
$('mem-summary').textContent=`${fmtBytes(h.mem_used)} / ${fmtBytes(h.mem_total)} (${h.mem_percent?.toFixed(1)}%)`;
renderMemBars(h);
// Load / Net / Freq
const la=h.load_avg||[0,0,0];
$('ov-load').textContent=la.map(v=>v.toFixed(2)).join(' / ');
$('ov-netconn').textContent=fmt(h.net_connections||0);
$('ov-freq').textContent=h.cpu_freq_mhz?h.cpu_freq_mhz+' MHz':'';
// Top processes
const procs=h.top_processes||[];
const ptb=$('proc-tbody');
if(procs.length){
ptb.innerHTML=procs.map(p=>{
const cpuC=p.cpu>50?'var(--red)':p.cpu>20?'var(--orange)':'var(--text)';
return `<tr><td class="mono">${p.pid}</td><td>${esc(p.name)}</td><td style="color:${cpuC};font-weight:600">${p.cpu}%</td><td>${p.mem}%</td></tr>`;
}).join('');
}else{ptb.innerHTML='<tr class="empty-row"><td colspan="4">No active processes</td></tr>';}
// Disks
const da=$('disk-area');
const disks=h.disk_usage||[];
if(disks.length){
da.innerHTML=disks.map(d=>{
const p=d.percent||0;const c=p>90?'var(--red)':p>70?'var(--orange)':'var(--accent)';
return `<div class="disk-row"><div class="disk-mount" title="${esc(d.mount)}">${esc(d.mount)}</div><div class="disk-bar-outer"><div class="disk-bar-inner" style="width:${p}%;background:${c}"></div></div><div class="disk-pct">${p.toFixed(1)}%</div><div style="font-size:.75rem;color:var(--text-dim);min-width:100px">${fmtBytes(d.used)} / ${fmtBytes(d.total)}</div></div>`;
}).join('');
}else{da.innerHTML='<div style="color:var(--text-dim);padding:8px">No disk info</div>';}
}
if(ts){
const bs=ts.by_severity||{};
$('ov-tc').textContent=fmt(bs.CRITICAL||0);
$('ov-th').textContent=fmt(bs.HIGH||0);
$('ov-tm').textContent=fmt(bs.MEDIUM||0);
$('ov-tl').textContent=fmt(bs.LOW||0);
}
if(ch&&ch.chart&&ch.chart.length){
drawBarChart('scan-canvas',ch.chart.slice(-14),{height:160});
}
}
/* THREATS */
async function loadThreats(){
const d=await api(`/api/threats?limit=200`);
if(!d)return;
S.threats=d.threats||[];
renderThreats();
}
function renderThreats(){
const sev=$('f-severity').value.toUpperCase();
const typ=$('f-type').value.toUpperCase();
const q=$('f-search').value.toLowerCase();
let f=S.threats;
if(sev)f=f.filter(t=>(t.severity||'').toUpperCase()===sev);
if(typ)f=f.filter(t=>(t.threat_type||'').toUpperCase()===typ);
if(q)f=f.filter(t=>(t.threat_name||'').toLowerCase().includes(q)||(t.file_path||'').toLowerCase().includes(q));
const total=f.length;const pages=Math.max(Math.ceil(total/S.threatPerPage),1);
S.threatPage=Math.min(S.threatPage,pages);
const start=(S.threatPage-1)*S.threatPerPage;
const slice=f.slice(start,start+S.threatPerPage);
const tb=$('threat-tbody');
if(!slice.length){tb.innerHTML='<tr class="empty-row"><td colspan="9">No threats detected ✅</td></tr>';
}else{tb.innerHTML=slice.map(t=>{
const act=t.action_taken||'detected';
const st=act==='detected'?'<span class="badge badge-warning">detected</span>':act==='quarantined'?'<span class="badge badge-info">quarantined</span>':statusBadge(act);
let btns='';
if(act==='detected'||act==='monitoring'){
btns=`<div style="display:flex;gap:4px;flex-wrap:wrap"><button class="btn btn-sm" style="background:var(--purple);color:#fff;border-color:var(--purple);font-size:.7rem;padding:3px 8px" onclick="aiAnalyze(${t.id},this)">🧠 AI Analyze</button><button class="btn btn-sm" style="background:var(--red);color:#fff;border-color:var(--red);font-size:.7rem;padding:3px 8px" onclick="threatAction('quarantine',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','${esc(t.threat_name).replace(/'/g,"\\'")}',this)">🔒 Quarantine</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('delete-threat',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">🗑 Delete</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('whitelist',${t.id},'','',this)"> Ignore</button></div>`;
} else if(act==='quarantined'){
btns=`<div style="display:flex;gap:4px;flex-wrap:wrap"><button class="btn btn-sm" style="background:var(--purple);color:#fff;border-color:var(--purple);font-size:.7rem;padding:3px 8px" onclick="aiAnalyze(${t.id},this)">🧠 AI Analyze</button><button class="btn btn-sm" style="background:var(--green);color:#fff;border-color:var(--green);font-size:.7rem;padding:3px 8px" onclick="threatAction('restore',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">♻️ Restore</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('delete-threat',${t.id},'${esc(t.file_path).replace(/'/g,"\\'")}','',this)">🗑️ Delete</button><button class="btn btn-sm" style="font-size:.7rem;padding:3px 8px" onclick="threatAction('whitelist',${t.id},'','',this)">✅ Ignore</button></div>`;
} else {
btns=`<span style="color:var(--text-dim);font-size:.75rem">${esc(act)}</span>`;
}
const det=t.details||'';
let aiCol='<span style="color:var(--text-dim);font-size:.75rem">—</span>';
const aiMatch=det.match(/\[AI:\s*(\w+)\s+(\d+)%\]\s*(.*)/);
if(aiMatch){const v=aiMatch[1],c=aiMatch[2],rsn=aiMatch[3];const vc=v==='safe'?'var(--green)':v==='threat'?'var(--red)':'var(--orange)';aiCol=`<div style="font-size:.75rem"><span style="color:${vc};font-weight:700">${v.toUpperCase()}</span> <span style="color:var(--text-dim)">${c}%</span><div style="color:var(--text-dim);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(rsn)}">${esc(rsn)}</div></div>`;}
return `<tr><td>${ago(t.timestamp)}</td><td class="mono trunc" title="${esc(t.file_path)}">${esc(trunc(t.file_path,50))}</td><td>${esc(t.threat_name)}</td><td>${esc(t.threat_type)}</td><td>${sevBadge(t.severity)}</td><td>${esc(t.detector)}</td><td>${aiCol}</td><td>${st}</td><td>${btns}</td></tr>`;
}).join('');}
$('threat-page').textContent=`Page ${S.threatPage} of ${pages} (${total})`;
}
$('threat-prev').onclick=()=>{S.threatPage=Math.max(1,S.threatPage-1);renderThreats();};
$('threat-next').onclick=()=>{S.threatPage++;renderThreats();};
$('f-severity').onchange=$('f-type').onchange=()=>{S.threatPage=1;renderThreats();};
let _tTimer;$('f-search').oninput=()=>{clearTimeout(_tTimer);_tTimer=setTimeout(()=>{S.threatPage=1;renderThreats();},300);};
/* SCANS */
async function loadScans(){
const [sc,ch]=await Promise.all([api('/api/scans?limit=30'),api('/api/scan-chart?days=30')]);
if(sc){
const tb=$('scan-tbody');
const scans=sc.scans||[];
if(!scans.length){tb.innerHTML='<tr class="empty-row"><td colspan="7">No scans yet</td></tr>';
}else{tb.innerHTML=scans.map(s=>`<tr><td>${ago(s.timestamp)}</td><td>${esc(s.scan_type)}</td><td class="mono trunc" title="${esc(s.scan_path)}">${esc(trunc(s.scan_path,40))}</td><td>${fmt(s.files_scanned)}</td><td>${s.threats_found?'<span style="color:var(--red)">'+s.threats_found+'</span>':'0'}</td><td>${fmtDur(s.duration_seconds)}</td><td>${statusBadge(s.status)}</td></tr>`).join('');}
}
if(ch&&ch.chart&&ch.chart.length)drawBarChart('scan-chart-canvas',ch.chart,{height:160});
}
/* DEFINITIONS */
Q('#def-subtabs .sub-tab').forEach(t=>t.addEventListener('click',()=>{
Q('#def-subtabs .sub-tab').forEach(x=>x.classList.remove('active'));
t.classList.add('active');S.defType=t.dataset.def;S.defPage=1;loadDefs();
}));
let _dTimer;$('def-search').oninput=()=>{clearTimeout(_dTimer);_dTimer=setTimeout(()=>{S.defPage=1;S.defSearch=$('def-search').value;loadDefs();},400);};
function defPage(d){S.defPage=Math.max(1,S.defPage+d);loadDefs();}
async function loadDefs(){
const typ=S.defType==='all'?'':S.defType;
const q=encodeURIComponent(S.defSearch||'');
const [dd,su]=await Promise.all([
api(`/api/definitions?type=${typ}&page=${S.defPage}&per_page=${S.defPerPage}&search=${q}`),
api('/api/sig-updates?limit=10')
]);
if(dd){
$('def-hashes').textContent=fmt(dd.total_hashes);
$('def-ips').textContent=fmt(dd.total_ips);
$('def-domains').textContent=fmt(dd.total_domains);
$('def-urls').textContent=fmt(dd.total_urls);
renderDefTable(dd);
const total=dd.total_hashes+dd.total_ips+dd.total_domains+dd.total_urls;
const pages=Math.max(Math.ceil(total/S.defPerPage),1);
$('def-page-info').textContent=`Page ${S.defPage} of ${pages}`;
}
if(su){
const tb=$('sigup-tbody');
const ups=su.updates||[];
if(!ups.length){tb.innerHTML='<tr class="empty-row"><td colspan="7">No updates yet</td></tr>';
}else{tb.innerHTML=ups.map(u=>`<tr><td>${ago(u.timestamp)}</td><td>${esc(u.feed_name)}</td><td>${fmt(u.hashes_added)}</td><td>${fmt(u.ips_added)}</td><td>${fmt(u.domains_added)}</td><td>${fmt(u.urls_added)}</td><td>${statusBadge(u.status)}</td></tr>`).join('');}
}
}
function renderDefTable(dd){
const th=$('def-thead');const tb=$('def-tbody');
let rows=[];const t=S.defType;
if(t==='all'||t==='hash'){
th.innerHTML='<tr><th>Hash</th><th>Threat Name</th><th>Type</th><th>Severity</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.hashes||[]).map(r=>`<tr><td class="mono">${esc(trunc(r.hash,16))}</td><td>${esc(r.threat_name)}</td><td>${esc(r.threat_type)}</td><td>${sevBadge(r.severity)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='ip'){
if(t==='ip')th.innerHTML='<tr><th>IP Address</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.ips||[]).map(r=>`<tr><td class="mono">${esc(r.ip)}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='domain'){
if(t==='domain')th.innerHTML='<tr><th>Domain</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.domains||[]).map(r=>`<tr><td class="mono">${esc(r.domain)}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'||t==='url'){
if(t==='url')th.innerHTML='<tr><th>URL</th><th>Threat Name</th><th>Type</th><th>Source</th><th>Date</th></tr>';
rows=rows.concat((dd.urls||[]).map(r=>`<tr><td class="mono trunc" title="${esc(r.url)}">${esc(trunc(r.url,60))}</td><td>${esc(r.threat_name)}</td><td>${esc(r.type)}</td><td>${esc(r.source)}</td><td>${ago(r.added_date)}</td></tr>`));
}
if(t==='all'&&!rows.length&&!dd.hashes?.length)th.innerHTML='<tr><th>Hash</th><th>Threat Name</th><th>Type</th><th>Severity</th><th>Source</th><th>Date</th></tr>';
tb.innerHTML=rows.length?rows.join(''):'<tr class="empty-row"><td colspan="6">No definitions found. Run an update to fetch threat feeds.</td></tr>';
}
/* CONTAINERS */
async function loadContainers(){
const [cl,cs]=await Promise.all([api('/api/containers'),api('/api/container-scan')]);
if(cl){
$('ct-count').textContent=fmt(cl.count);
$('ct-runtimes').textContent=cl.runtimes.length?cl.runtimes.join(', '):'None detected';
const tb=$('ct-tbody');
const cc=cl.containers||[];
if(!cc.length){tb.innerHTML='<tr class="empty-row"><td colspan="8">No containers found. Install Docker, Podman, or LXC.</td></tr>';
}else{
tb.innerHTML=cc.map(c=>{
const st=c.status==='running'?'<span class="badge badge-success">running</span>':c.status==='stopped'?'<span class="badge badge-error">stopped</span>':'<span class="badge badge-warning">'+esc(c.status)+'</span>';
const ports=(c.ports||[]).slice(0,3).join(', ')||(c.status==='running'?'':'');
return `<tr><td class="mono">${esc(trunc(c.container_id,12))}</td><td>${esc(c.name)}</td><td class="mono trunc" title="${esc(c.image)}">${esc(trunc(c.image,30))}</td><td>${esc(c.runtime)}</td><td>${st}</td><td class="mono">${esc(c.ip_address||'')}</td><td class="mono" style="font-size:.72rem">${esc(ports)}</td><td><button class="btn btn-sm" onclick="scanSingleContainer('${esc(c.container_id)}',this)">Scan</button></td></tr>`;
}).join('');
}
}
if(cs){
const threats=cs.threats||[];
$('ct-threats').textContent=fmt(threats.length);
const tb=$('ct-threat-tbody');
if(!threats.length){tb.innerHTML='<tr class="empty-row"><td colspan="6">No container threats ✅</td></tr>';
}else{tb.innerHTML=threats.map(t=>`<tr><td>${ago(t.timestamp)}</td><td>${esc(trunc(t.file_path,30))}</td><td>${esc(t.threat_name)}</td><td>${esc(t.threat_type)}</td><td>${sevBadge(t.severity)}</td><td class="trunc" title="${esc(t.details)}">${esc(trunc(t.details,60))}</td></tr>`).join('');}
}
}
async function scanSingleContainer(id,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span>';btn.disabled=true;
toast(`Scanning container ${id.slice(0,12)}`,'info');
try{
const r=await fetch('/api/actions/scan-container',{method:'POST',headers:{'Content-Type':'application/json','X-API-Key':window.AYN_API_KEY||''},body:JSON.stringify({container_id:id})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast('Server error: '+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(d.error||'Failed','error');}
else{toast(`Container scan: ${d.threats_found||0} threats found`,'success');}
loadContainers();
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* QUARANTINE */
async function loadQuarantine(){
const d=await api('/api/quarantine');
if(!d)return;
$('q-count').textContent=fmt(d.count);
$('q-size').textContent=fmtBytes(d.total_size);
const tb=$('quar-tbody');
const items=d.items||[];
if(!items.length){tb.innerHTML='<tr class="empty-row"><td colspan="5">Vault is empty ✅</td></tr>';
}else{tb.innerHTML=items.map(i=>`<tr><td class="mono">${esc(trunc(i.id,12))}</td><td class="mono trunc" title="${esc(i.original_path)}">${esc(trunc(i.original_path,50))}</td><td>${esc(i.threat_name)}</td><td>${ago(i.quarantine_date)}</td><td>${fmtBytes(i.size||i.file_size||0)}</td></tr>`).join('');}
}
/* LOGS */
async function loadLogs(){
const d=await api('/api/logs?limit=50');
if(!d)return;
const lv=$('log-view');
const logs=d.logs||[];
if(!logs.length){lv.innerHTML='<div style="color:var(--text-dim);padding:12px">No activity yet.</div>';return;}
lv.innerHTML=logs.map(l=>{
const lc=l.level==='ERROR'?'var(--red)':l.level==='WARNING'?'var(--orange)':'var(--accent)';
return `<div class="log-line"><span class="log-ts">${l.timestamp||''}</span><span style="color:${lc};font-weight:700;min-width:56px">${l.level}</span><span class="log-src">${esc(l.source)}</span><span class="log-msg">${esc(l.message)}</span></div>`;
}).join('');
lv.scrollTop=0;
}
/* AI ANALYSIS */
async function aiAnalyze(threatId,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span> Analyzing…';btn.disabled=true;
try{
const r=await fetch('/api/actions/ai-analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({threat_id:threatId})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){toast('Server error','error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='ok'){
const emoji=d.verdict==='safe'?'':d.verdict==='threat'?'🚨':'⚠️';
const color=d.verdict==='safe'?'success':d.verdict==='threat'?'error':'info';
toast(`${emoji} AI: ${d.verdict.toUpperCase()} (${d.confidence}%) ${d.reason}`,color);
if(d.verdict==='safe'){toast(`Recommended: ${d.recommended_action}`,'info');}
loadThreats();
} else {toast(d.error||'AI analysis failed','error');}
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* THREAT ACTIONS */
async function threatAction(action,threatId,filePath,threatName,btn){
const labels={'quarantine':'Quarantine','delete-threat':'Delete','whitelist':'Whitelist'};
if(action==='delete-threat'&&!confirm('Permanently delete '+filePath+'?'))return;
const orig=btn.parentElement.innerHTML;btn.parentElement.innerHTML='<span class="spinner"></span>';
try{
const body={threat_id:threatId};
if(filePath)body.file_path=filePath;
if(threatName)body.threat_name=threatName;
const r=await fetch('/api/actions/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){toast('Server error','error');return;}
const d=await r.json();
if(d.status==='ok'){toast((labels[action]||action)+' done','success');loadThreats();}
else{toast(d.error||'Failed','error');}
}catch(e){toast('Failed: '+e.message,'error');}
}
/* ACTIONS */
async function doAction(action,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span> Running…';btn.disabled=true;
toast(`${action} started`,'info');
try{
const r=await fetch(`/api/actions/${action}`,{method:'POST',headers:{'X-API-Key':window.AYN_API_KEY||''}});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast('Server error: '+(r.status>=400?r.status+' ':'')+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(d.error||'Failed','error');}
else{toast(`${action} completed`,'success');}
refreshAll();
}catch(e){toast('Request failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
async function doFeedUpdate(feed,btn){
const orig=btn.innerHTML;btn.innerHTML='<span class="spinner"></span>';btn.disabled=true;
toast(`Updating ${feed}`,'info');
try{
const r=await fetch('/api/actions/update-feed',{method:'POST',headers:{'Content-Type':'application/json','X-API-Key':window.AYN_API_KEY||''},body:JSON.stringify({feed})});
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const t=await r.text();toast(`${feed}: Server error `+t.slice(0,100),'error');btn.innerHTML=orig;btn.disabled=false;return;}
const d=await r.json();
if(d.status==='error'){toast(`${feed}: ${d.error}`,'error');}
else{toast(`${feed} updated`,'success');}
loadDefs();
}catch(e){toast('Failed: '+e.message,'error');}
btn.innerHTML=orig;btn.disabled=false;
}
/* REFRESH */
async function refreshAll(){
await loadOverview();
const active=document.querySelector('.nav-tab.active');
if(active){
const tab=active.dataset.tab;
if(tab==='threats')loadThreats();
if(tab==='scans')loadScans();
if(tab==='definitions')loadDefs();
if(tab==='containers')loadContainers();
if(tab==='quarantine')loadQuarantine();
if(tab==='logs')loadLogs();
}
}
/* Boot */
refreshAll();
setInterval(refreshAll,30000);
</script>
</body>
</html>"""
@@ -0,0 +1,20 @@
"""AYN Antivirus detector modules."""
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector
from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector
from ayn_antivirus.detectors.rootkit_detector import RootkitDetector
from ayn_antivirus.detectors.signature_detector import SignatureDetector
from ayn_antivirus.detectors.spyware_detector import SpywareDetector
from ayn_antivirus.detectors.yara_detector import YaraDetector
__all__ = [
"BaseDetector",
"DetectionResult",
"CryptominerDetector",
"HeuristicDetector",
"RootkitDetector",
"SignatureDetector",
"SpywareDetector",
"YaraDetector",
]
@@ -0,0 +1,268 @@
"""AYN Antivirus — AI-Powered Threat Analyzer.
Uses Claude to analyze suspicious files and filter false positives.
Each detection from heuristic/signature scanners is verified by AI
before being reported as a real threat.
"""
from __future__ import annotations
import json
import logging
import os
import platform
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """Linux VPS antivirus analyst. {environment}
Normal: pip/npm scripts in /usr/local/bin, Docker hex IDs, cron jobs (fstrim/certbot/logrotate), high-entropy archives, curl/wget in deploy scripts, recently-modified files after apt/pip.
Reply ONLY JSON: {{"verdict":"threat"|"safe"|"suspicious","confidence":0-100,"reason":"short","recommended_action":"quarantine"|"delete"|"ignore"|"monitor"}}"""
ANALYSIS_PROMPT = """FILE:{file_path} DETECT:{threat_name}({threat_type}) SEV:{severity} DET:{detector} CONF:{original_confidence}% SIZE:{file_size} PERM:{permissions} OWN:{owner} MOD:{mtime}
PREVIEW:
{content_preview}
JSON verdict:"""
@dataclass
class AIVerdict:
"""Result of AI analysis on a detection."""
verdict: str # threat, safe, suspicious
confidence: int # 0-100
reason: str
recommended_action: str # quarantine, delete, ignore, monitor
raw_response: str = ""
@property
def is_threat(self) -> bool:
return self.verdict == "threat"
@property
def is_safe(self) -> bool:
return self.verdict == "safe"
class AIAnalyzer:
"""AI-powered threat analysis using Claude."""
def __init__(self, api_key: Optional[str] = None, model: str = "claude-sonnet-4-20250514"):
self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "") or self._load_key_from_env_file()
self._model = model
self._client = None
self._environment = self._detect_environment()
@staticmethod
def _load_key_from_env_file() -> str:
for p in ["/opt/ayn-antivirus/.env", Path.home() / ".ayn-antivirus" / ".env"]:
try:
for line in Path(p).read_text().splitlines():
line = line.strip()
if line.startswith("ANTHROPIC_API_KEY=") and not line.endswith("="):
return line.split("=", 1)[1].strip().strip("'\"")
except Exception:
pass
return ""
@property
def available(self) -> bool:
return bool(self._api_key)
def _get_client(self):
if not self._client:
try:
import anthropic
self._client = anthropic.Anthropic(api_key=self._api_key)
except Exception as exc:
logger.error("Failed to init Anthropic client: %s", exc)
return None
return self._client
@staticmethod
def _detect_environment() -> str:
"""Gather environment context for the AI."""
import shutil
parts = [
f"OS: {platform.system()} {platform.release()}",
f"Hostname: {platform.node()}",
f"Arch: {platform.machine()}",
]
if shutil.which("incus"):
parts.append("Container runtime: Incus/LXC (containers run Docker inside)")
if shutil.which("docker"):
parts.append("Docker: available")
if Path("/etc/dokploy").exists() or shutil.which("dokploy"):
parts.append("Platform: Dokploy (Docker deployment platform)")
# Check if we're inside a container
if Path("/run/host/container-manager").exists():
parts.append("Running inside: managed container")
return "\n".join(parts)
def _get_file_context(self, file_path: str) -> Dict[str, Any]:
"""Gather file metadata and content preview."""
p = Path(file_path)
ctx = {
"file_size": 0,
"permissions": "",
"owner": "",
"mtime": "",
"content_preview": "[file not readable]",
}
try:
st = p.stat()
ctx["file_size"] = st.st_size
ctx["permissions"] = oct(st.st_mode)[-4:]
ctx["mtime"] = str(st.st_mtime)
try:
import pwd
ctx["owner"] = pwd.getpwuid(st.st_uid).pw_name
except Exception:
ctx["owner"] = str(st.st_uid)
except OSError:
pass
try:
with open(file_path, "rb") as f:
raw = f.read(512)
# Try text decode, fall back to hex
try:
ctx["content_preview"] = raw.decode("utf-8", errors="replace")
except Exception:
ctx["content_preview"] = raw.hex()[:512]
except Exception:
pass
return ctx
def analyze(
self,
file_path: str,
threat_name: str,
threat_type: str,
severity: str,
detector: str,
confidence: int = 50,
) -> AIVerdict:
"""Analyze a single detection with AI."""
if not self.available:
# No API key — pass through as-is
return AIVerdict(
verdict="suspicious",
confidence=confidence,
reason="AI analysis unavailable (no API key)",
recommended_action="quarantine",
)
client = self._get_client()
if not client:
return AIVerdict(
verdict="suspicious",
confidence=confidence,
reason="AI client init failed",
recommended_action="quarantine",
)
ctx = self._get_file_context(file_path)
# Sanitize content preview to avoid format string issues
preview = ctx.get("content_preview", "")
if len(preview) > 500:
preview = preview[:500] + "..."
# Replace curly braces to avoid format() issues
preview = preview.replace("{", "{{").replace("}", "}}")
user_msg = ANALYSIS_PROMPT.format(
file_path=file_path,
threat_name=threat_name,
threat_type=threat_type,
severity=severity,
detector=detector,
original_confidence=confidence,
file_size=ctx.get("file_size", 0),
permissions=ctx.get("permissions", ""),
owner=ctx.get("owner", ""),
mtime=ctx.get("mtime", ""),
content_preview=preview,
)
text = ""
try:
response = client.messages.create(
model=self._model,
max_tokens=150,
system=SYSTEM_PROMPT.format(environment=self._environment),
messages=[{"role": "user", "content": user_msg}],
)
text = response.content[0].text.strip()
# Parse JSON from response (handle markdown code blocks)
if "```" in text:
parts = text.split("```")
for part in parts[1:]:
cleaned = part.strip()
if cleaned.startswith("json"):
cleaned = cleaned[4:].strip()
if cleaned.startswith("{"):
text = cleaned
break
# Find the JSON object in the response
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
text = text[start:end]
data = json.loads(text)
return AIVerdict(
verdict=data.get("verdict", "suspicious"),
confidence=data.get("confidence", 50),
reason=data.get("reason", ""),
recommended_action=data.get("recommended_action", "quarantine"),
raw_response=text,
)
except json.JSONDecodeError as exc:
logger.warning("AI returned non-JSON: %s — raw: %s", exc, text[:200])
return AIVerdict(
verdict="suspicious",
confidence=confidence,
reason=f"AI parse error: {text[:100]}",
recommended_action="quarantine",
raw_response=text,
)
except Exception as exc:
logger.error("AI analysis failed: %s", exc)
return AIVerdict(
verdict="suspicious",
confidence=confidence,
reason=f"AI error: {exc}",
recommended_action="quarantine",
)
def analyze_batch(
self,
detections: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Analyze a batch of detections. Returns enriched detections with AI verdicts.
Each detection dict should have: file_path, threat_name, threat_type, severity, detector
"""
results = []
for d in detections:
verdict = self.analyze(
file_path=d.get("file_path", ""),
threat_name=d.get("threat_name", ""),
threat_type=d.get("threat_type", ""),
severity=d.get("severity", "MEDIUM"),
detector=d.get("detector", ""),
confidence=d.get("confidence", 50),
)
enriched = dict(d)
enriched["ai_verdict"] = verdict.verdict
enriched["ai_confidence"] = verdict.confidence
enriched["ai_reason"] = verdict.reason
enriched["ai_action"] = verdict.recommended_action
results.append(enriched)
return results
@@ -0,0 +1,129 @@
"""Abstract base class and shared data structures for AYN detectors."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Detection result
# ---------------------------------------------------------------------------
@dataclass
class DetectionResult:
"""A single detection produced by a detector.
Attributes
----------
threat_name:
Short identifier for the threat (e.g. ``"Trojan.Miner.XMRig"``).
threat_type:
Category string ``VIRUS``, ``MALWARE``, ``SPYWARE``, ``MINER``,
``ROOTKIT``, ``HEURISTIC``, etc.
severity:
One of ``CRITICAL``, ``HIGH``, ``MEDIUM``, ``LOW``.
confidence:
How confident the detector is in the finding (0100).
details:
Human-readable explanation.
detector_name:
Which detector produced this result.
"""
threat_name: str
threat_type: str
severity: str
confidence: int
details: str
detector_name: str
# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------
class BaseDetector(ABC):
"""Interface that every AYN detector must implement.
Detectors receive a file path (and optionally pre-read content / hash)
and return zero or more :class:`DetectionResult` instances.
"""
# ------------------------------------------------------------------
# Identity
# ------------------------------------------------------------------
@property
@abstractmethod
def name(self) -> str:
"""Machine-friendly detector identifier."""
...
@property
@abstractmethod
def description(self) -> str:
"""One-line human-readable summary."""
...
# ------------------------------------------------------------------
# Detection
# ------------------------------------------------------------------
@abstractmethod
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
"""Run detection logic against a single file.
Parameters
----------
file_path:
Path to the file on disk.
file_content:
Optional pre-read bytes of the file (avoids double-read).
file_hash:
Optional pre-computed SHA-256 hex digest.
Returns
-------
list[DetectionResult]
Empty list when the file is clean.
"""
...
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _read_content(
self,
file_path: Path,
file_content: Optional[bytes],
max_bytes: int = 10 * 1024 * 1024,
) -> bytes:
"""Return *file_content* if provided, otherwise read from disk.
Reads at most *max_bytes* to avoid unbounded memory usage.
"""
if file_content is not None:
return file_content
with open(file_path, "rb") as fh:
return fh.read(max_bytes)
def _log(self, msg: str, *args) -> None:
logger.info("[%s] " + msg, self.name, *args)
def _warn(self, msg: str, *args) -> None:
logger.warning("[%s] " + msg, self.name, *args)
def _error(self, msg: str, *args) -> None:
logger.error("[%s] " + msg, self.name, *args)
@@ -0,0 +1,317 @@
"""Crypto-miner detector for AYN Antivirus.
Combines file-content analysis, process inspection, and network connection
checks to detect cryptocurrency mining activity on the host.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import List, Optional
import psutil
from ayn_antivirus.constants import (
CRYPTO_MINER_PROCESS_NAMES,
CRYPTO_POOL_DOMAINS,
HIGH_CPU_THRESHOLD,
SUSPICIOUS_PORTS,
)
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# File-content patterns
# ---------------------------------------------------------------------------
_RE_STRATUM = re.compile(rb"stratum\+(?:tcp|ssl|tls)://[^\s\"']+", re.IGNORECASE)
_RE_POOL_DOMAIN = re.compile(
rb"(?:" + b"|".join(re.escape(d.encode()) for d in CRYPTO_POOL_DOMAINS) + rb")",
re.IGNORECASE,
)
_RE_ALGO_REF = re.compile(
rb"\b(?:cryptonight|randomx|ethash|kawpow|equihash|scrypt|sha256d|x11|x13|lyra2rev2|blake2s)\b",
re.IGNORECASE,
)
_RE_MINING_CONFIG = re.compile(
rb"""["'](?:algo|pool|wallet|worker|pass|coin|url|user)["']\s*:\s*["']""",
re.IGNORECASE,
)
# Wallet address patterns (broad but useful).
_RE_BTC_ADDR = re.compile(rb"\b(?:1|3|bc1)[A-HJ-NP-Za-km-z1-9]{25,62}\b")
_RE_ETH_ADDR = re.compile(rb"\b0x[0-9a-fA-F]{40}\b")
_RE_XMR_ADDR = re.compile(rb"\b4[0-9AB][1-9A-HJ-NP-Za-km-z]{93}\b")
class CryptominerDetector(BaseDetector):
"""Detect cryptocurrency mining activity via files, processes, and network."""
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "cryptominer_detector"
@property
def description(self) -> str:
return "Detects crypto-mining binaries, configs, processes, and network traffic"
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
"""Analyse a file for mining indicators.
Also checks running processes and network connections for live mining
activity (these are host-wide and not specific to *file_path*, but
are included for a comprehensive picture).
"""
file_path = Path(file_path)
results: List[DetectionResult] = []
try:
content = self._read_content(file_path, file_content)
except OSError as exc:
self._warn("Cannot read %s: %s", file_path, exc)
return results
# --- File-content checks ---
results.extend(self._check_stratum_urls(file_path, content))
results.extend(self._check_pool_domains(file_path, content))
results.extend(self._check_algo_references(file_path, content))
results.extend(self._check_mining_config(file_path, content))
results.extend(self._check_wallet_addresses(file_path, content))
return results
# ------------------------------------------------------------------
# File-content checks
# ------------------------------------------------------------------
def _check_stratum_urls(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_STRATUM.findall(content)
if matches:
urls = [m.decode(errors="replace") for m in matches[:5]]
results.append(DetectionResult(
threat_name="Miner.Stratum.URL",
threat_type="MINER",
severity="CRITICAL",
confidence=95,
details=f"Stratum mining URL(s) found: {', '.join(urls)}",
detector_name=self.name,
))
return results
def _check_pool_domains(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_POOL_DOMAIN.findall(content)
if matches:
domains = sorted(set(m.decode(errors="replace") for m in matches))
results.append(DetectionResult(
threat_name="Miner.PoolDomain",
threat_type="MINER",
severity="HIGH",
confidence=90,
details=f"Mining pool domain(s) referenced: {', '.join(domains[:5])}",
detector_name=self.name,
))
return results
def _check_algo_references(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_ALGO_REF.findall(content)
if matches:
algos = sorted(set(m.decode(errors="replace").lower() for m in matches))
results.append(DetectionResult(
threat_name="Miner.AlgorithmReference",
threat_type="MINER",
severity="MEDIUM",
confidence=60,
details=f"Mining algorithm reference(s): {', '.join(algos)}",
detector_name=self.name,
))
return results
def _check_mining_config(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_MINING_CONFIG.findall(content)
if len(matches) >= 2:
results.append(DetectionResult(
threat_name="Miner.ConfigFile",
threat_type="MINER",
severity="HIGH",
confidence=85,
details=(
f"File resembles a mining configuration "
f"({len(matches)} config key(s) detected)"
),
detector_name=self.name,
))
return results
def _check_wallet_addresses(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
wallets: List[str] = []
for label, regex in [
("BTC", _RE_BTC_ADDR),
("ETH", _RE_ETH_ADDR),
("XMR", _RE_XMR_ADDR),
]:
matches = regex.findall(content)
for m in matches[:3]:
wallets.append(f"{label}:{m.decode(errors='replace')[:20]}")
if wallets:
results.append(DetectionResult(
threat_name="Miner.WalletAddress",
threat_type="MINER",
severity="HIGH",
confidence=70,
details=f"Cryptocurrency wallet address(es): {', '.join(wallets[:5])}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Process-based detection (host-wide, not file-specific)
# ------------------------------------------------------------------
@staticmethod
def find_miner_processes() -> List[DetectionResult]:
"""Scan running processes for known miner names.
This is a host-wide check and should be called independently from
the per-file ``detect()`` method.
"""
results: List[DetectionResult] = []
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent"]):
try:
info = proc.info
pname = (info.get("name") or "").lower()
cmdline = " ".join(info.get("cmdline") or []).lower()
for miner in CRYPTO_MINER_PROCESS_NAMES:
if miner in pname or miner in cmdline:
results.append(DetectionResult(
threat_name=f"Miner.Process.{miner}",
threat_type="MINER",
severity="CRITICAL",
confidence=95,
details=(
f"Known miner process running: {info.get('name')} "
f"(PID {info['pid']}, CPU {info.get('cpu_percent', 0):.1f}%)"
),
detector_name="cryptominer_detector",
))
break
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return results
# ------------------------------------------------------------------
# CPU analysis (host-wide)
# ------------------------------------------------------------------
@staticmethod
def find_high_cpu_processes(
threshold: float = HIGH_CPU_THRESHOLD,
) -> List[DetectionResult]:
"""Flag processes consuming CPU above *threshold* percent."""
results: List[DetectionResult] = []
for proc in psutil.process_iter(["pid", "name", "cpu_percent"]):
try:
info = proc.info
cpu = info.get("cpu_percent") or 0.0
if cpu > threshold:
results.append(DetectionResult(
threat_name="Miner.HighCPU",
threat_type="MINER",
severity="HIGH",
confidence=55,
details=(
f"Process {info.get('name')} (PID {info['pid']}) "
f"using {cpu:.1f}% CPU"
),
detector_name="cryptominer_detector",
))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return results
# ------------------------------------------------------------------
# Network detection (host-wide)
# ------------------------------------------------------------------
@staticmethod
def find_mining_connections() -> List[DetectionResult]:
"""Check active network connections for mining pool traffic."""
results: List[DetectionResult] = []
try:
connections = psutil.net_connections(kind="inet")
except psutil.AccessDenied:
logger.warning("Insufficient permissions to read network connections")
return results
for conn in connections:
raddr = conn.raddr
if not raddr:
continue
remote_ip = raddr.ip
remote_port = raddr.port
proc_name = ""
if conn.pid:
try:
proc_name = psutil.Process(conn.pid).name()
except (psutil.NoSuchProcess, psutil.AccessDenied):
proc_name = "?"
if remote_port in SUSPICIOUS_PORTS:
results.append(DetectionResult(
threat_name="Miner.Network.SuspiciousPort",
threat_type="MINER",
severity="HIGH",
confidence=75,
details=(
f"Connection to port {remote_port} "
f"({remote_ip}, process={proc_name}, PID={conn.pid})"
),
detector_name="cryptominer_detector",
))
for domain in CRYPTO_POOL_DOMAINS:
if domain in remote_ip:
results.append(DetectionResult(
threat_name="Miner.Network.PoolConnection",
threat_type="MINER",
severity="CRITICAL",
confidence=95,
details=(
f"Active connection to mining pool {domain} "
f"({remote_ip}:{remote_port}, process={proc_name})"
),
detector_name="cryptominer_detector",
))
break
return results
@@ -0,0 +1,436 @@
"""Heuristic detector for AYN Antivirus.
Uses statistical and pattern-based analysis to flag files that *look*
malicious even when no signature or YARA rule matches. Checks include
Shannon entropy (packed/encrypted binaries), suspicious string patterns,
obfuscation indicators, ELF anomalies, and permission/location red flags.
"""
from __future__ import annotations
import logging
import math
import re
import stat
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional
from ayn_antivirus.constants import SUSPICIOUS_EXTENSIONS
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Thresholds
# ---------------------------------------------------------------------------
_HIGH_ENTROPY_THRESHOLD = 7.5 # bits per byte — likely packed / encrypted
_CHR_CHAIN_MIN = 6 # minimum chr()/\xNN sequence length
_B64_MIN_LENGTH = 40 # minimum base64 blob considered suspicious
# ---------------------------------------------------------------------------
# Compiled regexes (built once at import time)
# ---------------------------------------------------------------------------
_RE_BASE64_BLOB = re.compile(
rb"(?:(?:[A-Za-z0-9+/]{4}){10,})(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?"
)
_RE_EVAL_EXEC = re.compile(rb"\b(?:eval|exec|compile)\s*\(", re.IGNORECASE)
_RE_SYSTEM_CALL = re.compile(
rb"\b(?:os\.system|subprocess\.(?:call|run|Popen)|commands\.getoutput)\s*\(",
re.IGNORECASE,
)
_RE_REVERSE_SHELL = re.compile(
rb"(?:/dev/tcp/|bash\s+-i\s+>&|nc\s+-[elp]|ncat\s+-|socat\s+|python[23]?\s+-c\s+['\"]import\s+socket)",
re.IGNORECASE,
)
_RE_WGET_CURL_PIPE = re.compile(
rb"(?:wget|curl)\s+[^\n]*\|\s*(?:sh|bash|python|perl)", re.IGNORECASE
)
_RE_ENCODED_PS = re.compile(
rb"-(?:enc(?:odedcommand)?|e|ec)\s+[A-Za-z0-9+/=]{20,}", re.IGNORECASE
)
_RE_CHR_CHAIN = re.compile(
rb"(?:chr\s*\(\s*\d+\s*\)\s*[\.\+]\s*){" + str(_CHR_CHAIN_MIN).encode() + rb",}",
re.IGNORECASE,
)
_RE_HEX_STRING = re.compile(
rb"(?:\\x[0-9a-fA-F]{2}){8,}"
)
_RE_STRING_CONCAT = re.compile(
rb"""(?:["'][^"']{1,4}["']\s*[\+\.]\s*){6,}""",
)
# UPX magic at the beginning of packed sections.
_UPX_MAGIC = b"UPX!"
# System directories where world-writable or SUID files are suspicious.
_SYSTEM_DIRS = {"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/local/sbin"}
# Locations where hidden files are suspicious.
_SUSPICIOUS_HIDDEN_DIRS = {"/tmp", "/var/tmp", "/dev/shm", "/var/www", "/srv"}
class HeuristicDetector(BaseDetector):
"""Flag files that exhibit suspicious characteristics without a known signature."""
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "heuristic_detector"
@property
def description(self) -> str:
return "Statistical and pattern-based heuristic analysis"
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
file_path = Path(file_path)
results: List[DetectionResult] = []
try:
content = self._read_content(file_path, file_content)
except OSError as exc:
self._warn("Cannot read %s: %s", file_path, exc)
return results
# --- Entropy analysis ---
results.extend(self._check_entropy(file_path, content))
# --- Suspicious string patterns ---
results.extend(self._check_suspicious_strings(file_path, content))
# --- Obfuscation indicators ---
results.extend(self._check_obfuscation(file_path, content))
# --- ELF anomalies ---
results.extend(self._check_elf_anomalies(file_path, content))
# --- Permission / location anomalies ---
results.extend(self._check_permission_anomalies(file_path))
# --- Hidden files in suspicious locations ---
results.extend(self._check_hidden_files(file_path))
# --- Recently modified system files ---
results.extend(self._check_recent_system_modification(file_path))
return results
# ------------------------------------------------------------------
# Entropy
# ------------------------------------------------------------------
@staticmethod
def calculate_entropy(data: bytes) -> float:
"""Calculate Shannon entropy (bits per byte) of *data*.
Returns a value between 0.0 (uniform) and 8.0 (maximum randomness).
"""
if not data:
return 0.0
length = len(data)
freq = Counter(data)
entropy = 0.0
for count in freq.values():
p = count / length
if p > 0:
entropy -= p * math.log2(p)
return entropy
def _check_entropy(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if len(content) < 256:
return results # too short for meaningful entropy
entropy = self.calculate_entropy(content)
if entropy > _HIGH_ENTROPY_THRESHOLD:
results.append(DetectionResult(
threat_name="Heuristic.Packed.HighEntropy",
threat_type="MALWARE",
severity="MEDIUM",
confidence=65,
details=(
f"File entropy {entropy:.2f} bits/byte exceeds threshold "
f"({_HIGH_ENTROPY_THRESHOLD}) — likely packed or encrypted"
),
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Suspicious strings
# ------------------------------------------------------------------
def _check_suspicious_strings(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
# Base64-encoded payloads.
b64_blobs = _RE_BASE64_BLOB.findall(content)
long_blobs = [b for b in b64_blobs if len(b) >= _B64_MIN_LENGTH]
if long_blobs:
results.append(DetectionResult(
threat_name="Heuristic.Obfuscation.Base64Payload",
threat_type="MALWARE",
severity="MEDIUM",
confidence=55,
details=f"Found {len(long_blobs)} large base64-encoded blob(s)",
detector_name=self.name,
))
# eval / exec / compile calls.
if _RE_EVAL_EXEC.search(content):
results.append(DetectionResult(
threat_name="Heuristic.Suspicious.DynamicExecution",
threat_type="MALWARE",
severity="MEDIUM",
confidence=50,
details="File uses eval()/exec()/compile() — possible code injection",
detector_name=self.name,
))
# os.system / subprocess calls.
if _RE_SYSTEM_CALL.search(content):
results.append(DetectionResult(
threat_name="Heuristic.Suspicious.SystemCall",
threat_type="MALWARE",
severity="MEDIUM",
confidence=45,
details="File invokes system commands via os.system/subprocess",
detector_name=self.name,
))
# Reverse shell patterns.
match = _RE_REVERSE_SHELL.search(content)
if match:
results.append(DetectionResult(
threat_name="Heuristic.ReverseShell",
threat_type="MALWARE",
severity="CRITICAL",
confidence=85,
details=f"Reverse shell pattern detected: {match.group()[:80]!r}",
detector_name=self.name,
))
# wget/curl piped to sh/bash.
if _RE_WGET_CURL_PIPE.search(content):
results.append(DetectionResult(
threat_name="Heuristic.Dropper.PipeToShell",
threat_type="MALWARE",
severity="HIGH",
confidence=80,
details="File downloads and pipes directly to a shell interpreter",
detector_name=self.name,
))
# Encoded PowerShell command.
if _RE_ENCODED_PS.search(content):
results.append(DetectionResult(
threat_name="Heuristic.PowerShell.EncodedCommand",
threat_type="MALWARE",
severity="HIGH",
confidence=75,
details="Encoded PowerShell command detected",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Obfuscation
# ------------------------------------------------------------------
def _check_obfuscation(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
# chr() chains.
if _RE_CHR_CHAIN.search(content):
results.append(DetectionResult(
threat_name="Heuristic.Obfuscation.ChrChain",
threat_type="MALWARE",
severity="MEDIUM",
confidence=60,
details="Obfuscation via long chr() concatenation chain",
detector_name=self.name,
))
# Hex-encoded byte strings.
hex_matches = _RE_HEX_STRING.findall(content)
if len(hex_matches) > 3:
results.append(DetectionResult(
threat_name="Heuristic.Obfuscation.HexStrings",
threat_type="MALWARE",
severity="MEDIUM",
confidence=55,
details=f"Multiple hex-encoded strings detected ({len(hex_matches)} occurrences)",
detector_name=self.name,
))
# Excessive string concatenation.
if _RE_STRING_CONCAT.search(content):
results.append(DetectionResult(
threat_name="Heuristic.Obfuscation.StringConcat",
threat_type="MALWARE",
severity="LOW",
confidence=40,
details="Excessive short-string concatenation — possible obfuscation",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# ELF anomalies
# ------------------------------------------------------------------
def _check_elf_anomalies(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if not content[:4] == b"\x7fELF":
return results
# UPX packed.
if _UPX_MAGIC in content[:4096]:
results.append(DetectionResult(
threat_name="Heuristic.Packed.UPX",
threat_type="MALWARE",
severity="MEDIUM",
confidence=60,
details="ELF binary is UPX-packed",
detector_name=self.name,
))
# Stripped binary in unusual location.
path_str = str(file_path)
is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS)
if not is_in_system:
# Non-system ELF — more suspicious if stripped (no .symtab).
if b".symtab" not in content and b".debug" not in content:
results.append(DetectionResult(
threat_name="Heuristic.ELF.StrippedNonSystem",
threat_type="MALWARE",
severity="LOW",
confidence=35,
details="Stripped ELF binary found outside standard system directories",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Permission anomalies
# ------------------------------------------------------------------
def _check_permission_anomalies(
self, file_path: Path
) -> List[DetectionResult]:
results: List[DetectionResult] = []
try:
st = file_path.stat()
except OSError:
return results
mode = st.st_mode
path_str = str(file_path)
# World-writable file in a system directory.
is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS)
if is_in_system and (mode & stat.S_IWOTH):
results.append(DetectionResult(
threat_name="Heuristic.Permissions.WorldWritableSystem",
threat_type="MALWARE",
severity="HIGH",
confidence=70,
details=f"World-writable file in system directory: {file_path}",
detector_name=self.name,
))
# SUID/SGID on unusual files.
is_suid = bool(mode & stat.S_ISUID)
is_sgid = bool(mode & stat.S_ISGID)
if (is_suid or is_sgid) and not is_in_system:
flag = "SUID" if is_suid else "SGID"
results.append(DetectionResult(
threat_name=f"Heuristic.Permissions.{flag}NonSystem",
threat_type="MALWARE",
severity="HIGH",
confidence=75,
details=f"{flag} bit set on file outside system directories: {file_path}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Hidden files in suspicious locations
# ------------------------------------------------------------------
def _check_hidden_files(
self, file_path: Path
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if not file_path.name.startswith("."):
return results
path_str = str(file_path)
for sus_dir in _SUSPICIOUS_HIDDEN_DIRS:
if path_str.startswith(sus_dir):
results.append(DetectionResult(
threat_name="Heuristic.HiddenFile.SuspiciousLocation",
threat_type="MALWARE",
severity="MEDIUM",
confidence=50,
details=f"Hidden file in suspicious directory: {file_path}",
detector_name=self.name,
))
break
return results
# ------------------------------------------------------------------
# Recently modified system files
# ------------------------------------------------------------------
def _check_recent_system_modification(
self, file_path: Path
) -> List[DetectionResult]:
results: List[DetectionResult] = []
path_str = str(file_path)
is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS)
if not is_in_system:
return results
try:
mtime = datetime.utcfromtimestamp(file_path.stat().st_mtime)
except OSError:
return results
if datetime.utcnow() - mtime < timedelta(hours=24):
results.append(DetectionResult(
threat_name="Heuristic.SystemFile.RecentlyModified",
threat_type="MALWARE",
severity="MEDIUM",
confidence=45,
details=(
f"System file modified within the last 24 hours: "
f"{file_path} (mtime: {mtime.isoformat()})"
),
detector_name=self.name,
))
return results
@@ -0,0 +1,387 @@
"""Rootkit detector for AYN Antivirus.
Performs system-wide checks for indicators of rootkit compromise: known
rootkit files, modified system binaries, hidden processes, hidden kernel
modules, LD_PRELOAD hijacking, hidden network ports, and tampered logs.
Many checks require **root** privileges. On non-Linux systems, kernel-
module and /proc-based checks are gracefully skipped.
"""
from __future__ import annotations
import logging
import os
import subprocess
from pathlib import Path
from typing import List, Optional, Set
import psutil
from ayn_antivirus.constants import (
KNOWN_ROOTKIT_FILES,
MALICIOUS_ENV_VARS,
)
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
logger = logging.getLogger(__name__)
class RootkitDetector(BaseDetector):
"""System-wide rootkit detection.
Unlike other detectors, the *file_path* argument is optional. When
called without a path (or with ``file_path=None``) the detector runs
every host-level check. When given a file it limits itself to checks
relevant to that file.
"""
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "rootkit_detector"
@property
def description(self) -> str:
return "Detects rootkits via file, process, module, and environment analysis"
def detect(
self,
file_path: str | Path | None = None,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
"""Run rootkit checks.
If *file_path* is ``None``, all system-wide checks are executed.
Otherwise only file-specific checks run.
"""
results: List[DetectionResult] = []
if file_path is not None:
fp = Path(file_path)
# File-specific: is this a known rootkit artefact?
results.extend(self._check_known_rootkit_file(fp))
return results
# --- Full system-wide scan ---
results.extend(self._check_known_rootkit_files())
results.extend(self._check_ld_preload())
results.extend(self._check_ld_so_preload())
results.extend(self._check_hidden_processes())
results.extend(self._check_hidden_kernel_modules())
results.extend(self._check_hidden_network_ports())
results.extend(self._check_malicious_env_vars())
results.extend(self._check_tampered_logs())
return results
# ------------------------------------------------------------------
# Known rootkit files
# ------------------------------------------------------------------
def _check_known_rootkit_files(self) -> List[DetectionResult]:
"""Check every path in :pydata:`KNOWN_ROOTKIT_FILES`."""
results: List[DetectionResult] = []
for path_str in KNOWN_ROOTKIT_FILES:
p = Path(path_str)
if p.exists():
results.append(DetectionResult(
threat_name="Rootkit.KnownFile",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=90,
details=f"Known rootkit artefact present: {path_str}",
detector_name=self.name,
))
return results
def _check_known_rootkit_file(self, file_path: Path) -> List[DetectionResult]:
"""Check whether *file_path* is a known rootkit file."""
results: List[DetectionResult] = []
path_str = str(file_path)
if path_str in KNOWN_ROOTKIT_FILES:
results.append(DetectionResult(
threat_name="Rootkit.KnownFile",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=90,
details=f"Known rootkit artefact: {path_str}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# LD_PRELOAD / ld.so.preload
# ------------------------------------------------------------------
def _check_ld_preload(self) -> List[DetectionResult]:
"""Flag the ``LD_PRELOAD`` environment variable if set globally."""
results: List[DetectionResult] = []
val = os.environ.get("LD_PRELOAD", "")
if val:
results.append(DetectionResult(
threat_name="Rootkit.LDPreload.EnvVar",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=85,
details=f"LD_PRELOAD is set: {val}",
detector_name=self.name,
))
return results
def _check_ld_so_preload(self) -> List[DetectionResult]:
"""Check ``/etc/ld.so.preload`` for suspicious entries."""
results: List[DetectionResult] = []
ld_preload_file = Path("/etc/ld.so.preload")
if not ld_preload_file.exists():
return results
try:
content = ld_preload_file.read_text().strip()
except PermissionError:
self._warn("Cannot read /etc/ld.so.preload")
return results
if content:
lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("#")]
if lines:
results.append(DetectionResult(
threat_name="Rootkit.LDPreload.File",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=85,
details=f"/etc/ld.so.preload contains entries: {', '.join(lines[:5])}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Hidden processes
# ------------------------------------------------------------------
def _check_hidden_processes(self) -> List[DetectionResult]:
"""Compare /proc PIDs with psutil to find hidden processes."""
results: List[DetectionResult] = []
proc_dir = Path("/proc")
if not proc_dir.is_dir():
return results # non-Linux
proc_pids: Set[int] = set()
try:
for entry in proc_dir.iterdir():
if entry.name.isdigit():
proc_pids.add(int(entry.name))
except PermissionError:
return results
psutil_pids = set(psutil.pids())
hidden = proc_pids - psutil_pids
for pid in hidden:
name = ""
try:
comm = proc_dir / str(pid) / "comm"
if comm.exists():
name = comm.read_text().strip()
except OSError:
pass
results.append(DetectionResult(
threat_name="Rootkit.HiddenProcess",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=85,
details=f"PID {pid} ({name or 'unknown'}) visible in /proc but hidden from psutil",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Hidden kernel modules
# ------------------------------------------------------------------
def _check_hidden_kernel_modules(self) -> List[DetectionResult]:
"""Compare ``lsmod`` output with ``/proc/modules`` to find discrepancies."""
results: List[DetectionResult] = []
proc_modules_path = Path("/proc/modules")
if not proc_modules_path.exists():
return results # non-Linux
# Modules from /proc/modules.
try:
proc_content = proc_modules_path.read_text()
except PermissionError:
return results
proc_mods: Set[str] = set()
for line in proc_content.splitlines():
parts = line.split()
if parts:
proc_mods.add(parts[0])
# Modules from lsmod.
lsmod_mods: Set[str] = set()
try:
output = subprocess.check_output(["lsmod"], stderr=subprocess.DEVNULL, timeout=10)
for line in output.decode(errors="replace").splitlines()[1:]:
parts = line.split()
if parts:
lsmod_mods.add(parts[0])
except (FileNotFoundError, subprocess.SubprocessError, OSError):
return results # lsmod not available
# Modules in /proc but NOT in lsmod → hidden from userspace.
hidden = proc_mods - lsmod_mods
for mod in hidden:
results.append(DetectionResult(
threat_name="Rootkit.HiddenKernelModule",
threat_type="ROOTKIT",
severity="CRITICAL",
confidence=80,
details=f"Kernel module '{mod}' in /proc/modules but hidden from lsmod",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Hidden network ports
# ------------------------------------------------------------------
def _check_hidden_network_ports(self) -> List[DetectionResult]:
"""Compare ``ss``/``netstat`` listening ports with psutil."""
results: List[DetectionResult] = []
# Ports from psutil.
psutil_ports: Set[int] = set()
try:
for conn in psutil.net_connections(kind="inet"):
if conn.status == "LISTEN" and conn.laddr:
psutil_ports.add(conn.laddr.port)
except psutil.AccessDenied:
return results
# Ports from ss.
ss_ports: Set[int] = set()
try:
output = subprocess.check_output(
["ss", "-tlnH"], stderr=subprocess.DEVNULL, timeout=10
)
for line in output.decode(errors="replace").splitlines():
# Typical ss output: LISTEN 0 128 0.0.0.0:22 ...
parts = line.split()
for part in parts:
if ":" in part:
try:
port = int(part.rsplit(":", 1)[1])
ss_ports.add(port)
except (ValueError, IndexError):
continue
except (FileNotFoundError, subprocess.SubprocessError, OSError):
return results # ss not available
# Ports in ss but not in psutil → potentially hidden by a rootkit.
hidden = ss_ports - psutil_ports
for port in hidden:
results.append(DetectionResult(
threat_name="Rootkit.HiddenPort",
threat_type="ROOTKIT",
severity="HIGH",
confidence=70,
details=f"Listening port {port} visible to ss but hidden from psutil",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Malicious environment variables
# ------------------------------------------------------------------
def _check_malicious_env_vars(self) -> List[DetectionResult]:
"""Check the current environment for known-risky variables."""
results: List[DetectionResult] = []
for entry in MALICIOUS_ENV_VARS:
if "=" in entry:
# Exact key=value match (e.g. "HISTFILE=/dev/null").
key, val = entry.split("=", 1)
if os.environ.get(key) == val:
results.append(DetectionResult(
threat_name="Rootkit.EnvVar.Suspicious",
threat_type="ROOTKIT",
severity="HIGH",
confidence=75,
details=f"Suspicious environment variable: {key}={val}",
detector_name=self.name,
))
else:
# Key presence check (e.g. "LD_PRELOAD").
if entry in os.environ:
results.append(DetectionResult(
threat_name="Rootkit.EnvVar.Suspicious",
threat_type="ROOTKIT",
severity="HIGH",
confidence=65,
details=f"Suspicious environment variable set: {entry}={os.environ[entry][:100]}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Tampered log files
# ------------------------------------------------------------------
_LOG_PATHS = [
"/var/log/auth.log",
"/var/log/syslog",
"/var/log/messages",
"/var/log/secure",
"/var/log/wtmp",
"/var/log/btmp",
"/var/log/lastlog",
]
def _check_tampered_logs(self) -> List[DetectionResult]:
"""Look for signs of log tampering: zero-byte logs, missing logs,
or logs whose mtime is suspiciously older than expected.
"""
results: List[DetectionResult] = []
for log_path_str in self._LOG_PATHS:
log_path = Path(log_path_str)
if not log_path.exists():
# Missing critical log.
if log_path_str in ("/var/log/auth.log", "/var/log/syslog", "/var/log/wtmp"):
results.append(DetectionResult(
threat_name="Rootkit.Log.Missing",
threat_type="ROOTKIT",
severity="HIGH",
confidence=60,
details=f"Critical log file missing: {log_path_str}",
detector_name=self.name,
))
continue
try:
st = log_path.stat()
except OSError:
continue
# Zero-byte log file (may have been truncated).
if st.st_size == 0:
results.append(DetectionResult(
threat_name="Rootkit.Log.Truncated",
threat_type="ROOTKIT",
severity="HIGH",
confidence=70,
details=f"Log file is empty (possibly truncated): {log_path_str}",
detector_name=self.name,
))
return results
@@ -0,0 +1,192 @@
"""AYN Antivirus — Signature-based Detector.
Looks up file hashes against the threat signature database populated by
the feed update pipeline (MalwareBazaar, ThreatFox, etc.). Uses
:class:`~ayn_antivirus.signatures.db.hash_db.HashDatabase` so that
definitions written by ``ayn-antivirus update`` are immediately available
for detection.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Dict, List, Optional
from ayn_antivirus.constants import DEFAULT_DB_PATH
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
from ayn_antivirus.utils.helpers import hash_file as _hash_file_util
logger = logging.getLogger("ayn_antivirus.detectors.signature")
_VALID_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW"}
class SignatureDetector(BaseDetector):
"""Detect known malware by matching file hashes against the signature DB.
Parameters
----------
db_path:
Path to the shared SQLite database that holds the ``threats``,
``ioc_ips``, ``ioc_domains``, and ``ioc_urls`` tables.
"""
def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None:
self.db_path = str(db_path)
self._hash_db = None
self._ioc_db = None
self._loaded = False
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "signature_detector"
@property
def description(self) -> str:
return "Hash-based signature detection using threat intelligence feeds"
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
"""Check the file's hash against the ``threats`` table.
If *file_hash* is not supplied it is computed on the fly.
"""
self._ensure_loaded()
results: List[DetectionResult] = []
if not self._hash_db:
return results
# Compute hash if not provided.
if not file_hash:
try:
file_hash = _hash_file_util(str(file_path), algo="sha256")
except Exception:
return results
# Also compute MD5 for VirusShare lookups.
md5_hash = None
try:
md5_hash = _hash_file_util(str(file_path), algo="md5")
except Exception:
pass
# Look up SHA256 first, then MD5.
threat = self._hash_db.lookup(file_hash)
if not threat and md5_hash:
threat = self._hash_db.lookup(md5_hash)
if threat:
severity = (threat.get("severity") or "HIGH").upper()
if severity not in _VALID_SEVERITIES:
severity = "HIGH"
results.append(DetectionResult(
threat_name=threat.get("threat_name", "Malware.Known"),
threat_type=threat.get("threat_type", "MALWARE"),
severity=severity,
confidence=100,
details=(
f"Known threat signature match "
f"(source: {threat.get('source', 'unknown')}). "
f"Hash: {file_hash[:16]}... "
f"Details: {threat.get('details', '')}"
),
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# IOC lookup helpers (used by engine for network enrichment)
# ------------------------------------------------------------------
def lookup_hash(self, file_hash: str) -> Optional[Dict]:
"""Look up a single hash. Returns threat info dict or ``None``."""
self._ensure_loaded()
if not self._hash_db:
return None
return self._hash_db.lookup(file_hash)
def lookup_ip(self, ip: str) -> Optional[Dict]:
"""Look up an IP against the IOC database."""
self._ensure_loaded()
if not self._ioc_db:
return None
return self._ioc_db.lookup_ip(ip)
def lookup_domain(self, domain: str) -> Optional[Dict]:
"""Look up a domain against the IOC database."""
self._ensure_loaded()
if not self._ioc_db:
return None
return self._ioc_db.lookup_domain(domain)
# ------------------------------------------------------------------
# Statistics
# ------------------------------------------------------------------
def get_stats(self) -> Dict:
"""Return signature / IOC database statistics."""
self._ensure_loaded()
stats: Dict = {"hash_count": 0, "loaded": self._loaded}
if self._hash_db:
stats["hash_count"] = self._hash_db.count()
stats.update(self._hash_db.get_stats())
if self._ioc_db:
stats["ioc_ips"] = len(self._ioc_db.get_all_malicious_ips())
stats["ioc_domains"] = len(self._ioc_db.get_all_malicious_domains())
return stats
@property
def signature_count(self) -> int:
"""Number of hash signatures currently loaded."""
self._ensure_loaded()
return self._hash_db.count() if self._hash_db else 0
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def close(self) -> None:
"""Close database connections."""
if self._hash_db:
self._hash_db.close()
self._hash_db = None
if self._ioc_db:
self._ioc_db.close()
self._ioc_db = None
self._loaded = False
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _ensure_loaded(self) -> None:
"""Lazy-load the database connections on first use."""
if self._loaded:
return
if not self.db_path:
logger.warning("No signature DB path configured")
self._loaded = True
return
try:
from ayn_antivirus.signatures.db.hash_db import HashDatabase
from ayn_antivirus.signatures.db.ioc_db import IOCDatabase
self._hash_db = HashDatabase(self.db_path)
self._hash_db.initialize()
self._ioc_db = IOCDatabase(self.db_path)
self._ioc_db.initialize()
count = self._hash_db.count()
logger.info("Signature DB loaded: %d hash signatures", count)
except Exception as exc:
logger.error("Failed to load signature DB: %s", exc)
self._loaded = True
@@ -0,0 +1,366 @@
"""Spyware detector for AYN Antivirus.
Scans files and system state for indicators of spyware: keyloggers, screen
capture utilities, data exfiltration patterns, reverse shells, unauthorized
SSH keys, and suspicious shell-profile modifications.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Optional
from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
# ---------------------------------------------------------------------------
# File-content patterns
# ---------------------------------------------------------------------------
# Keylogger indicators.
_RE_KEYLOGGER = re.compile(
rb"(?:"
rb"/dev/input/event\d+"
rb"|xinput\s+(?:test|list)"
rb"|xdotool\b"
rb"|showkey\b"
rb"|logkeys\b"
rb"|pynput\.keyboard"
rb"|keyboard\.on_press"
rb"|evdev\.InputDevice"
rb"|GetAsyncKeyState"
rb"|SetWindowsHookEx"
rb")",
re.IGNORECASE,
)
# Screen / audio capture.
_RE_SCREEN_CAPTURE = re.compile(
rb"(?:"
rb"scrot\b"
rb"|import\s+-window\s+root"
rb"|xwd\b"
rb"|ffmpeg\s+.*-f\s+x11grab"
rb"|xdpyinfo"
rb"|ImageGrab\.grab"
rb"|screenshot"
rb"|pyautogui\.screenshot"
rb"|screencapture\b"
rb")",
re.IGNORECASE,
)
_RE_AUDIO_CAPTURE = re.compile(
rb"(?:"
rb"arecord\b"
rb"|parecord\b"
rb"|ffmpeg\s+.*-f\s+(?:alsa|pulse|avfoundation)"
rb"|pyaudio"
rb"|sounddevice"
rb")",
re.IGNORECASE,
)
# Data exfiltration.
_RE_EXFIL = re.compile(
rb"(?:"
rb"curl\s+.*-[FdT]\s"
rb"|curl\s+.*--upload-file"
rb"|wget\s+.*--post-file"
rb"|scp\s+.*@"
rb"|rsync\s+.*@"
rb"|nc\s+-[^\s]*\s+\d+\s*<"
rb"|python[23]?\s+-m\s+http\.server"
rb")",
re.IGNORECASE,
)
# Reverse shell.
_RE_REVERSE_SHELL = re.compile(
rb"(?:"
rb"bash\s+-i\s+>&\s*/dev/tcp/"
rb"|nc\s+-e\s+/bin/"
rb"|ncat\s+.*-e\s+/bin/"
rb"|socat\s+exec:"
rb"|python[23]?\s+-c\s+['\"]import\s+socket"
rb"|perl\s+-e\s+['\"]use\s+Socket"
rb"|ruby\s+-rsocket\s+-e"
rb"|php\s+-r\s+['\"].*fsockopen"
rb"|mkfifo\s+/tmp/.*;\s*nc"
rb"|/dev/tcp/\d+\.\d+\.\d+\.\d+"
rb")",
re.IGNORECASE,
)
# Suspicious cron patterns (compiled from constants).
_RE_CRON_PATTERNS = [
re.compile(pat.encode(), re.IGNORECASE) for pat in SUSPICIOUS_CRON_PATTERNS
]
class SpywareDetector(BaseDetector):
"""Detect spyware indicators in files and on the host."""
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "spyware_detector"
@property
def description(self) -> str:
return "Detects keyloggers, screen capture, data exfiltration, and reverse shells"
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
file_path = Path(file_path)
results: List[DetectionResult] = []
try:
content = self._read_content(file_path, file_content)
except OSError as exc:
self._warn("Cannot read %s: %s", file_path, exc)
return results
# --- File-content checks ---
results.extend(self._check_keylogger(file_path, content))
results.extend(self._check_screen_capture(file_path, content))
results.extend(self._check_audio_capture(file_path, content))
results.extend(self._check_exfiltration(file_path, content))
results.extend(self._check_reverse_shell(file_path, content))
results.extend(self._check_hidden_cron(file_path, content))
# --- Host-state checks (only for relevant paths) ---
results.extend(self._check_authorized_keys(file_path, content))
results.extend(self._check_shell_profile(file_path, content))
return results
# ------------------------------------------------------------------
# Keylogger patterns
# ------------------------------------------------------------------
def _check_keylogger(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_KEYLOGGER.findall(content)
if matches:
samples = sorted(set(m.decode(errors="replace") for m in matches[:5]))
results.append(DetectionResult(
threat_name="Spyware.Keylogger",
threat_type="SPYWARE",
severity="CRITICAL",
confidence=80,
details=f"Keylogger indicators: {', '.join(samples)}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Screen capture
# ------------------------------------------------------------------
def _check_screen_capture(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if _RE_SCREEN_CAPTURE.search(content):
results.append(DetectionResult(
threat_name="Spyware.ScreenCapture",
threat_type="SPYWARE",
severity="HIGH",
confidence=70,
details="Screen-capture tools or API calls detected",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Audio capture
# ------------------------------------------------------------------
def _check_audio_capture(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if _RE_AUDIO_CAPTURE.search(content):
results.append(DetectionResult(
threat_name="Spyware.AudioCapture",
threat_type="SPYWARE",
severity="HIGH",
confidence=65,
details="Audio recording tools or API calls detected",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Data exfiltration
# ------------------------------------------------------------------
def _check_exfiltration(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
matches = _RE_EXFIL.findall(content)
if matches:
samples = [m.decode(errors="replace")[:80] for m in matches[:3]]
results.append(DetectionResult(
threat_name="Spyware.DataExfiltration",
threat_type="SPYWARE",
severity="HIGH",
confidence=70,
details=f"Data exfiltration pattern(s): {'; '.join(samples)}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Reverse shell
# ------------------------------------------------------------------
def _check_reverse_shell(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
match = _RE_REVERSE_SHELL.search(content)
if match:
results.append(DetectionResult(
threat_name="Spyware.ReverseShell",
threat_type="SPYWARE",
severity="CRITICAL",
confidence=90,
details=f"Reverse shell pattern: {match.group()[:100]!r}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Hidden cron jobs
# ------------------------------------------------------------------
def _check_hidden_cron(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
# Only check cron-related files.
path_str = str(file_path)
is_cron = any(tok in path_str for tok in ("cron", "crontab", "/var/spool/"))
if not is_cron:
return results
for pat in _RE_CRON_PATTERNS:
match = pat.search(content)
if match:
results.append(DetectionResult(
threat_name="Spyware.Cron.SuspiciousEntry",
threat_type="SPYWARE",
severity="HIGH",
confidence=80,
details=f"Suspicious cron pattern in {file_path}: {match.group()[:80]!r}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Unauthorized SSH keys
# ------------------------------------------------------------------
def _check_authorized_keys(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if file_path.name != "authorized_keys":
return results
# Flag if the file exists in an unexpected location.
path_str = str(file_path)
if not path_str.startswith("/root/") and "/.ssh/" not in path_str:
results.append(DetectionResult(
threat_name="Spyware.SSH.UnauthorizedKeysFile",
threat_type="SPYWARE",
severity="HIGH",
confidence=75,
details=f"authorized_keys found in unexpected location: {file_path}",
detector_name=self.name,
))
# Check for suspiciously many keys.
key_count = content.count(b"ssh-rsa") + content.count(b"ssh-ed25519") + content.count(b"ecdsa-sha2")
if key_count > 10:
results.append(DetectionResult(
threat_name="Spyware.SSH.ExcessiveKeys",
threat_type="SPYWARE",
severity="MEDIUM",
confidence=55,
details=f"{key_count} SSH keys in {file_path} — possible unauthorized access",
detector_name=self.name,
))
# command= prefix can force a shell command on login — often abused.
if b'command="' in content or b"command='" in content:
results.append(DetectionResult(
threat_name="Spyware.SSH.ForcedCommand",
threat_type="SPYWARE",
severity="MEDIUM",
confidence=60,
details=f"Forced command found in authorized_keys: {file_path}",
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Shell profile modifications
# ------------------------------------------------------------------
_PROFILE_FILES = {
".bashrc", ".bash_profile", ".profile", ".zshrc",
".bash_login", ".bash_logout",
}
_RE_PROFILE_SUSPICIOUS = re.compile(
rb"(?:"
rb"curl\s+[^\n]*\|\s*(?:sh|bash)"
rb"|wget\s+[^\n]*\|\s*(?:sh|bash)"
rb"|/dev/tcp/"
rb"|base64\s+--decode"
rb"|nohup\s+.*&"
rb"|eval\s+\$\("
rb"|python[23]?\s+-c\s+['\"]import\s+(?:socket|os|pty)"
rb")",
re.IGNORECASE,
)
def _check_shell_profile(
self, file_path: Path, content: bytes
) -> List[DetectionResult]:
results: List[DetectionResult] = []
if file_path.name not in self._PROFILE_FILES:
return results
match = self._RE_PROFILE_SUSPICIOUS.search(content)
if match:
results.append(DetectionResult(
threat_name="Spyware.ShellProfile.SuspiciousEntry",
threat_type="SPYWARE",
severity="CRITICAL",
confidence=85,
details=(
f"Suspicious command in shell profile {file_path}: "
f"{match.group()[:100]!r}"
),
detector_name=self.name,
))
return results
@@ -0,0 +1,200 @@
"""YARA-rule detector for AYN Antivirus.
Compiles and caches YARA rule files from the configured rules directory,
then matches them against scanned files. ``yara-python`` is treated as an
optional dependency if it is missing the detector logs a warning and
returns no results.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, List, Optional
from ayn_antivirus.constants import DEFAULT_YARA_RULES_DIR
from ayn_antivirus.detectors.base import BaseDetector, DetectionResult
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Conditional import — yara-python is optional.
# ---------------------------------------------------------------------------
try:
import yara # type: ignore[import-untyped]
_YARA_AVAILABLE = True
except ImportError:
_YARA_AVAILABLE = False
yara = None # type: ignore[assignment]
# Severity mapping for YARA rule meta tags.
_META_SEVERITY_MAP = {
"critical": "CRITICAL",
"high": "HIGH",
"medium": "MEDIUM",
"low": "LOW",
}
class YaraDetector(BaseDetector):
"""Detect threats by matching YARA rules against file contents.
Parameters
----------
rules_dir:
Directory containing ``.yar`` / ``.yara`` rule files. Defaults to
the bundled ``signatures/yara_rules/`` directory.
"""
def __init__(self, rules_dir: str | Path = DEFAULT_YARA_RULES_DIR) -> None:
self.rules_dir = Path(rules_dir)
self._rules: Any = None # compiled yara.Rules object
self._rule_count: int = 0
self._loaded = False
# ------------------------------------------------------------------
# BaseDetector interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "yara_detector"
@property
def description(self) -> str:
return "Pattern matching using compiled YARA rules"
def detect(
self,
file_path: str | Path,
file_content: Optional[bytes] = None,
file_hash: Optional[str] = None,
) -> List[DetectionResult]:
"""Match all loaded YARA rules against *file_path*.
Falls back to in-memory matching if *file_content* is provided.
"""
if not _YARA_AVAILABLE:
self._warn("yara-python is not installed — skipping YARA detection")
return []
if not self._loaded:
self.load_rules()
if self._rules is None:
return []
file_path = Path(file_path)
results: List[DetectionResult] = []
try:
if file_content is not None:
matches = self._rules.match(data=file_content)
else:
matches = self._rules.match(filepath=str(file_path))
except yara.Error as exc:
self._warn("YARA scan failed for %s: %s", file_path, exc)
return results
for match in matches:
meta = match.meta or {}
severity = _META_SEVERITY_MAP.get(
str(meta.get("severity", "")).lower(), "HIGH"
)
threat_type = meta.get("threat_type", "MALWARE").upper()
threat_name = meta.get("threat_name") or match.rule
matched_strings = []
try:
for offset, identifier, data in match.strings:
matched_strings.append(
f"{identifier} @ 0x{offset:x}"
)
except (TypeError, ValueError):
# match.strings format varies between yara-python versions.
pass
detail_parts = [f"YARA rule '{match.rule}' matched"]
if match.namespace and match.namespace != "default":
detail_parts.append(f"namespace={match.namespace}")
if matched_strings:
detail_parts.append(
f"strings=[{', '.join(matched_strings[:5])}]"
)
if meta.get("description"):
detail_parts.append(meta["description"])
results.append(DetectionResult(
threat_name=threat_name,
threat_type=threat_type,
severity=severity,
confidence=int(meta.get("confidence", 90)),
details=" | ".join(detail_parts),
detector_name=self.name,
))
return results
# ------------------------------------------------------------------
# Rule management
# ------------------------------------------------------------------
def load_rules(self, rules_dir: Optional[str | Path] = None) -> None:
"""Compile all ``.yar`` / ``.yara`` files in *rules_dir*.
Compiled rules are cached in ``self._rules``. Call this again
after updating rule files to pick up changes.
"""
if not _YARA_AVAILABLE:
self._warn("yara-python is not installed — cannot load rules")
return
directory = Path(rules_dir) if rules_dir else self.rules_dir
if not directory.is_dir():
self._warn("YARA rules directory does not exist: %s", directory)
return
rule_files = sorted(
p for p in directory.iterdir()
if p.suffix.lower() in (".yar", ".yara") and p.is_file()
)
if not rule_files:
self._log("No YARA rule files found in %s", directory)
self._rules = None
self._rule_count = 0
self._loaded = True
return
# Build a filepaths dict for yara.compile(filepaths={...}).
filepaths = {}
for idx, rf in enumerate(rule_files):
namespace = rf.stem
filepaths[namespace] = str(rf)
try:
self._rules = yara.compile(filepaths=filepaths)
self._rule_count = len(rule_files)
self._loaded = True
self._log(
"Compiled %d YARA rule file(s) from %s",
self._rule_count,
directory,
)
except yara.SyntaxError as exc:
self._error("YARA compilation error: %s", exc)
self._rules = None
except yara.Error as exc:
self._error("YARA error: %s", exc)
self._rules = None
@property
def rule_count(self) -> int:
"""Number of rule files currently compiled."""
return self._rule_count
@property
def available(self) -> bool:
"""Return ``True`` if ``yara-python`` is installed."""
return _YARA_AVAILABLE
@@ -0,0 +1,265 @@
"""Real-time file-system monitor for AYN Antivirus.
Uses the ``watchdog`` library to observe directories for file creation,
modification, and move events, then immediately scans the affected files
through the :class:`ScanEngine`. Supports debouncing, auto-quarantine,
and thread-safe operation.
"""
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from ayn_antivirus.config import Config
from ayn_antivirus.core.engine import ScanEngine, FileScanResult
from ayn_antivirus.core.event_bus import EventType, event_bus
from ayn_antivirus.quarantine.vault import QuarantineVault
logger = logging.getLogger(__name__)
# File suffixes that are almost always transient / editor artefacts.
_SKIP_SUFFIXES = frozenset((
".tmp", ".swp", ".swx", ".swo", ".lock", ".part",
".crdownload", ".kate-swp", ".~lock.", ".bak~",
))
# Minimum seconds between re-scanning the same path (debounce).
_DEBOUNCE_SECONDS = 2.0
# ---------------------------------------------------------------------------
# Watchdog event handler
# ---------------------------------------------------------------------------
class _FileEventHandler(FileSystemEventHandler):
"""Internal handler that bridges watchdog events to the scan engine.
Parameters
----------
monitor:
The owning :class:`RealtimeMonitor` instance.
"""
def __init__(self, monitor: RealtimeMonitor) -> None:
super().__init__()
self._monitor = monitor
# Only react to file events (not directories).
def on_created(self, event: FileSystemEvent) -> None:
if not event.is_directory:
self._monitor._on_file_event(event.src_path, "created")
def on_modified(self, event: FileSystemEvent) -> None:
if not event.is_directory:
self._monitor._on_file_event(event.src_path, "modified")
def on_moved(self, event: FileSystemEvent) -> None:
if not event.is_directory:
dest = getattr(event, "dest_path", None)
if dest:
self._monitor._on_file_event(dest, "moved")
# ---------------------------------------------------------------------------
# RealtimeMonitor
# ---------------------------------------------------------------------------
class RealtimeMonitor:
"""Watch directories and scan new / changed files in real time.
Parameters
----------
config:
Application configuration.
scan_engine:
A pre-built :class:`ScanEngine` instance used to scan files.
"""
def __init__(self, config: Config, scan_engine: ScanEngine) -> None:
self.config = config
self.engine = scan_engine
self._observer: Optional[Observer] = None
self._lock = threading.Lock()
self._recent: Dict[str, float] = {} # path → last-scan timestamp
self._running = False
# Optional auto-quarantine vault.
self._vault: Optional[QuarantineVault] = None
if config.auto_quarantine:
self._vault = QuarantineVault(config.quarantine_path)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self, paths: Optional[List[str]] = None, recursive: bool = True) -> None:
"""Begin monitoring *paths* (defaults to ``config.scan_paths``).
Parameters
----------
paths:
Directories to watch.
recursive:
Watch subdirectories as well.
"""
watch_paths = paths or self.config.scan_paths
with self._lock:
if self._running:
logger.warning("RealtimeMonitor is already running")
return
self._observer = Observer()
handler = _FileEventHandler(self)
for p in watch_paths:
pp = Path(p)
if not pp.is_dir():
logger.warning("Skipping non-existent path: %s", p)
continue
self._observer.schedule(handler, str(pp), recursive=recursive)
logger.info("Watching: %s (recursive=%s)", pp, recursive)
self._observer.start()
self._running = True
logger.info("RealtimeMonitor started — watching %d path(s)", len(watch_paths))
event_bus.publish(EventType.SCAN_STARTED, {
"type": "realtime_monitor",
"paths": watch_paths,
})
def stop(self) -> None:
"""Stop monitoring and wait for the observer thread to exit."""
with self._lock:
if not self._running or self._observer is None:
return
self._observer.stop()
self._observer.join(timeout=10)
with self._lock:
self._running = False
self._observer = None
logger.info("RealtimeMonitor stopped")
@property
def is_running(self) -> bool:
with self._lock:
return self._running
# ------------------------------------------------------------------
# Event callbacks (called by _FileEventHandler)
# ------------------------------------------------------------------
def on_file_created(self, path: str) -> None:
"""Scan a newly created file."""
self._scan_file(path, "created")
def on_file_modified(self, path: str) -> None:
"""Scan a modified file."""
self._scan_file(path, "modified")
def on_file_moved(self, path: str) -> None:
"""Scan a file that was moved/renamed into a watched directory."""
self._scan_file(path, "moved")
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _on_file_event(self, path: str, event_type: str) -> None:
"""Central dispatcher invoked by the watchdog handler."""
if self._should_skip(path):
return
if self._is_debounced(path):
return
logger.debug("File event: %s %s", event_type, path)
# Dispatch to the named callback (also usable directly).
if event_type == "created":
self.on_file_created(path)
elif event_type == "modified":
self.on_file_modified(path)
elif event_type == "moved":
self.on_file_moved(path)
def _scan_file(self, path: str, reason: str) -> None:
"""Run the scan engine against a single file and handle results."""
fp = Path(path)
if not fp.is_file():
return
try:
result: FileScanResult = self.engine.scan_file(fp)
except Exception:
logger.exception("Error scanning %s", fp)
return
if result.threats:
logger.warning(
"THREAT detected (%s) in %s: %s",
reason,
path,
", ".join(t.threat_name for t in result.threats),
)
# Auto-quarantine if enabled.
if self._vault and fp.exists():
try:
threat = result.threats[0]
qid = self._vault.quarantine_file(fp, {
"threat_name": threat.threat_name,
"threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type),
"severity": threat.severity.name if hasattr(threat.severity, "name") else str(threat.severity),
"file_hash": result.file_hash,
})
logger.info("Auto-quarantined %s%s", path, qid)
except Exception:
logger.exception("Auto-quarantine failed for %s", path)
else:
logger.debug("Clean: %s (%s)", path, reason)
# ------------------------------------------------------------------
# Debounce & skip logic
# ------------------------------------------------------------------
def _is_debounced(self, path: str) -> bool:
"""Return ``True`` if *path* was scanned within the debounce window."""
now = time.monotonic()
with self._lock:
last = self._recent.get(path, 0.0)
if now - last < _DEBOUNCE_SECONDS:
return True
self._recent[path] = now
# Prune stale entries periodically.
if len(self._recent) > 5000:
cutoff = now - _DEBOUNCE_SECONDS * 2
self._recent = {
k: v for k, v in self._recent.items() if v > cutoff
}
return False
@staticmethod
def _should_skip(path: str) -> bool:
"""Return ``True`` for temporary / lock / editor backup files."""
name = Path(path).name.lower()
if any(name.endswith(s) for s in _SKIP_SUFFIXES):
return True
# Hidden editor temp files like .#foo or 4913 (vim temp).
if name.startswith(".#"):
return True
return False
@@ -0,0 +1,378 @@
"""Encrypted quarantine vault for AYN Antivirus.
Isolates malicious files by encrypting them with Fernet (AES-128-CBC +
HMAC-SHA256) and storing them alongside JSON metadata in a dedicated
vault directory. Files can be restored, inspected, or permanently deleted.
"""
from __future__ import annotations
import fcntl
import json
import logging
import os
import re
import shutil
import stat
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import uuid4
from cryptography.fernet import Fernet
from ayn_antivirus.constants import (
DEFAULT_QUARANTINE_PATH,
QUARANTINE_ENCRYPTION_KEY_FILE,
SCAN_CHUNK_SIZE,
)
from ayn_antivirus.core.event_bus import EventType, event_bus
logger = logging.getLogger(__name__)
class QuarantineVault:
"""Encrypted file quarantine vault.
Parameters
----------
quarantine_dir:
Directory where encrypted files and metadata are stored.
key_file_path:
Path to the Fernet key file. Generated automatically on first use.
"""
_VALID_QID_PATTERN = re.compile(r'^[a-f0-9]{32}$')
# Directories that should never be a restore destination.
_BLOCKED_DIRS = frozenset({
Path("/etc"), Path("/usr/bin"), Path("/usr/sbin"), Path("/sbin"),
Path("/bin"), Path("/boot"), Path("/root/.ssh"), Path("/proc"),
Path("/sys"), Path("/dev"), Path("/var/run"),
})
# Directories used for scheduled tasks — never restore into these.
_CRON_DIRS = frozenset({
Path("/etc/cron.d"), Path("/etc/cron.daily"),
Path("/etc/cron.hourly"), Path("/var/spool/cron"),
Path("/etc/systemd"),
})
def __init__(
self,
quarantine_dir: str | Path = DEFAULT_QUARANTINE_PATH,
key_file_path: str | Path = QUARANTINE_ENCRYPTION_KEY_FILE,
) -> None:
self.vault_dir = Path(quarantine_dir)
self.key_file = Path(key_file_path)
self._fernet: Optional[Fernet] = None
# Ensure directories exist.
self.vault_dir.mkdir(parents=True, exist_ok=True)
self.key_file.parent.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Input validation
# ------------------------------------------------------------------
def _validate_qid(self, quarantine_id: str) -> str:
"""Validate quarantine ID is a hex UUID (no path traversal).
Raises :class:`ValueError` if the ID does not match the expected
32-character hexadecimal format.
"""
qid = quarantine_id.strip()
if not self._VALID_QID_PATTERN.match(qid):
raise ValueError(
f"Invalid quarantine ID format: {quarantine_id!r} "
f"(must be 32 hex chars)"
)
return qid
def _validate_restore_path(self, path_str: str) -> Path:
"""Validate restore path to prevent directory traversal.
Blocks restoring to sensitive system directories and scheduled-
task directories. Resolves all paths to handle symlinks like
``/etc`` ``/private/etc`` on macOS.
"""
dest = Path(path_str).resolve()
for blocked in self._BLOCKED_DIRS:
resolved = blocked.resolve()
if dest == resolved or resolved in dest.parents or dest.parent == resolved:
raise ValueError(f"Refusing to restore to protected path: {dest}")
for cron_dir in self._CRON_DIRS:
resolved = cron_dir.resolve()
if resolved in dest.parents or dest.parent == resolved:
raise ValueError(
f"Refusing to restore to scheduled task directory: {dest}"
)
return dest
# ------------------------------------------------------------------
# Key management
# ------------------------------------------------------------------
def _get_fernet(self) -> Fernet:
"""Return the cached Fernet instance, loading or generating the key."""
if self._fernet is not None:
return self._fernet
if self.key_file.exists():
key = self.key_file.read_bytes().strip()
else:
key = Fernet.generate_key()
# Write key with restricted permissions.
fd = os.open(
str(self.key_file),
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
0o600,
)
try:
os.write(fd, key + b"\n")
finally:
os.close(fd)
logger.info("Generated new quarantine encryption key: %s", self.key_file)
self._fernet = Fernet(key)
return self._fernet
# ------------------------------------------------------------------
# Quarantine
# ------------------------------------------------------------------
def quarantine_file(
self,
file_path: str | Path,
threat_info: Dict[str, Any],
) -> str:
"""Encrypt and move a file into the vault.
Parameters
----------
file_path:
Path to the file to quarantine.
threat_info:
Metadata dict (typically from a detector result). Expected keys:
``threat_name``, ``threat_type``, ``severity``, ``file_hash``.
Returns
-------
str
The quarantine ID (UUID) for this entry.
"""
src = Path(file_path).resolve()
if not src.is_file():
raise FileNotFoundError(f"Cannot quarantine: {src} does not exist or is not a file")
qid = uuid4().hex
fernet = self._get_fernet()
# Lock, read, and encrypt (prevents TOCTOU races).
with open(src, "rb") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
plaintext = f.read()
st = os.fstat(f.fileno())
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
ciphertext = fernet.encrypt(plaintext)
# Gather metadata.
meta = {
"id": qid,
"original_path": str(src),
"original_permissions": oct(st.st_mode & 0o7777),
"threat_name": threat_info.get("threat_name", "Unknown"),
"threat_type": threat_info.get("threat_type", "MALWARE"),
"severity": threat_info.get("severity", "HIGH"),
"quarantine_date": datetime.utcnow().isoformat(),
"file_hash": threat_info.get("file_hash", ""),
"file_size": st.st_size,
}
# Write encrypted file + metadata.
enc_path = self.vault_dir / f"{qid}.enc"
meta_path = self.vault_dir / f"{qid}.json"
enc_path.write_bytes(ciphertext)
meta_path.write_text(json.dumps(meta, indent=2))
# Remove original.
try:
src.unlink()
logger.info("Quarantined %s%s (threat: %s)", src, qid, meta["threat_name"])
except OSError as exc:
logger.warning("Encrypted copy saved but failed to remove original %s: %s", src, exc)
event_bus.publish(EventType.QUARANTINE_ACTION, {
"action": "quarantine",
"quarantine_id": qid,
"original_path": str(src),
"threat_name": meta["threat_name"],
})
return qid
# ------------------------------------------------------------------
# Restore
# ------------------------------------------------------------------
def restore_file(
self,
quarantine_id: str,
restore_path: Optional[str | Path] = None,
) -> str:
"""Decrypt and restore a quarantined file.
Parameters
----------
quarantine_id:
UUID returned by :meth:`quarantine_file`.
restore_path:
Where to write the restored file. Defaults to the original path.
Returns
-------
str
Absolute path of the restored file.
Raises
------
ValueError
If the quarantine ID is malformed or the restore path points
to a protected system directory.
"""
qid = self._validate_qid(quarantine_id)
meta = self._load_meta(qid)
enc_path = self.vault_dir / f"{qid}.enc"
if not enc_path.exists():
raise FileNotFoundError(f"Encrypted file not found for quarantine ID {qid}")
# Validate restore destination.
if restore_path:
dest = self._validate_restore_path(str(restore_path))
else:
dest = self._validate_restore_path(str(meta["original_path"]))
fernet = self._get_fernet()
ciphertext = enc_path.read_bytes()
plaintext = fernet.decrypt(ciphertext)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(plaintext)
# Restore original permissions, stripping SUID/SGID/sticky bits.
try:
perms = int(meta.get("original_permissions", "0o644"), 8)
perms = perms & 0o0777 # Keep only rwx bits
dest.chmod(perms)
except (ValueError, OSError):
pass
logger.info("Restored quarantined file %s%s", qid, dest)
event_bus.publish(EventType.QUARANTINE_ACTION, {
"action": "restore",
"quarantine_id": qid,
"restored_path": str(dest),
})
return str(dest.resolve())
# ------------------------------------------------------------------
# Delete
# ------------------------------------------------------------------
def delete_file(self, quarantine_id: str) -> bool:
"""Permanently remove a quarantined entry (encrypted file + metadata).
Returns ``True`` if files were deleted.
"""
qid = self._validate_qid(quarantine_id)
enc_path = self.vault_dir / f"{qid}.enc"
meta_path = self.vault_dir / f"{qid}.json"
deleted = False
for p in (enc_path, meta_path):
if p.exists():
p.unlink()
deleted = True
if deleted:
logger.info("Permanently deleted quarantine entry: %s", qid)
event_bus.publish(EventType.QUARANTINE_ACTION, {
"action": "delete",
"quarantine_id": qid,
})
return deleted
# ------------------------------------------------------------------
# Listing / info
# ------------------------------------------------------------------
def list_quarantined(self) -> List[Dict[str, Any]]:
"""Return a summary list of all quarantined items."""
items: List[Dict[str, Any]] = []
for meta_file in sorted(self.vault_dir.glob("*.json")):
try:
meta = json.loads(meta_file.read_text())
items.append({
"id": meta.get("id", meta_file.stem),
"original_path": meta.get("original_path", "?"),
"threat_name": meta.get("threat_name", "?"),
"quarantine_date": meta.get("quarantine_date", "?"),
"size": meta.get("file_size", 0),
})
except (json.JSONDecodeError, OSError):
continue
return items
def get_info(self, quarantine_id: str) -> Dict[str, Any]:
"""Return full metadata for a quarantine entry.
Raises ``FileNotFoundError`` if the ID is unknown.
"""
qid = self._validate_qid(quarantine_id)
return self._load_meta(qid)
def count(self) -> int:
"""Number of items currently in the vault."""
return len(list(self.vault_dir.glob("*.json")))
# ------------------------------------------------------------------
# Maintenance
# ------------------------------------------------------------------
def clean_old(self, days: int = 30) -> int:
"""Delete quarantine entries older than *days*.
Returns the number of entries removed.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
removed = 0
for meta_file in self.vault_dir.glob("*.json"):
try:
meta = json.loads(meta_file.read_text())
qdate = datetime.fromisoformat(meta.get("quarantine_date", ""))
if qdate < cutoff:
qid = meta.get("id", meta_file.stem)
self.delete_file(qid)
removed += 1
except (json.JSONDecodeError, ValueError, OSError):
continue
if removed:
logger.info("Cleaned %d quarantine entries older than %d days", removed, days)
return removed
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _load_meta(self, quarantine_id: str) -> Dict[str, Any]:
qid = self._validate_qid(quarantine_id)
meta_path = self.vault_dir / f"{qid}.json"
if not meta_path.exists():
raise FileNotFoundError(f"Quarantine metadata not found: {qid}")
return json.loads(meta_path.read_text())
@@ -0,0 +1,544 @@
"""Automated remediation engine for AYN Antivirus.
Provides targeted fix actions for different threat types: permission
hardening, process killing, cron cleanup, SSH key auditing, startup
script removal, LD_PRELOAD cleaning, IP/domain blocking, and system
binary restoration via the system package manager.
All actions support a **dry-run** mode that logs intended changes without
modifying the system.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import stat
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import psutil
from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS
from ayn_antivirus.core.event_bus import EventType, event_bus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Action record
# ---------------------------------------------------------------------------
@dataclass
class RemediationAction:
"""Describes a single remediation step."""
action: str
target: str
details: str = ""
success: bool = False
dry_run: bool = False
# ---------------------------------------------------------------------------
# AutoPatcher
# ---------------------------------------------------------------------------
class AutoPatcher:
"""Apply targeted remediations against discovered threats.
Parameters
----------
dry_run:
If ``True``, no changes are made only the intended actions are
logged and returned.
"""
def __init__(self, dry_run: bool = False) -> None:
self.dry_run = dry_run
self.actions: List[RemediationAction] = []
# ------------------------------------------------------------------
# High-level dispatcher
# ------------------------------------------------------------------
def remediate_threat(self, threat_info: Dict[str, Any]) -> List[RemediationAction]:
"""Choose and execute the correct fix(es) for *threat_info*.
Routes on ``threat_type`` (MINER, ROOTKIT, SPYWARE, MALWARE, etc.)
and the available metadata.
"""
ttype = (threat_info.get("threat_type") or "").upper()
path = threat_info.get("path", "")
pid = threat_info.get("pid")
actions: List[RemediationAction] = []
# Kill associated process.
if pid:
actions.append(self.kill_malicious_process(int(pid)))
# Quarantine / permission fix for file-based threats.
if path and Path(path).exists():
actions.append(self.fix_permissions(path))
# Type-specific extras.
if ttype == "ROOTKIT":
actions.append(self.fix_ld_preload())
elif ttype == "MINER":
# Block known pool domains if we have one.
domain = threat_info.get("domain")
if domain:
actions.append(self.block_domain(domain))
ip = threat_info.get("ip")
if ip:
actions.append(self.block_ip(ip))
elif ttype == "SPYWARE":
if path and "cron" in path:
actions.append(self.remove_malicious_cron())
for a in actions:
self._publish(a)
self.actions.extend(actions)
return actions
# ------------------------------------------------------------------
# Permission fixes
# ------------------------------------------------------------------
def fix_permissions(self, path: str | Path) -> RemediationAction:
"""Remove SUID, SGID, and world-writable bits from *path*."""
p = Path(path)
action = RemediationAction(
action="fix_permissions",
target=str(p),
dry_run=self.dry_run,
)
try:
st = p.stat()
old_mode = st.st_mode
new_mode = old_mode
# Strip SUID / SGID.
new_mode &= ~stat.S_ISUID
new_mode &= ~stat.S_ISGID
# Strip world-writable.
new_mode &= ~stat.S_IWOTH
if new_mode == old_mode:
action.details = "Permissions already safe"
action.success = True
return action
action.details = (
f"Changing permissions: {oct(old_mode & 0o7777)}{oct(new_mode & 0o7777)}"
)
if not self.dry_run:
p.chmod(new_mode)
action.success = True
logger.info("fix_permissions: %s %s", action.details, "(dry-run)" if self.dry_run else "")
except OSError as exc:
action.details = f"Failed: {exc}"
logger.error("fix_permissions failed on %s: %s", p, exc)
return action
# ------------------------------------------------------------------
# Process killing
# ------------------------------------------------------------------
def kill_malicious_process(self, pid: int) -> RemediationAction:
"""Send SIGKILL to *pid*."""
action = RemediationAction(
action="kill_process",
target=str(pid),
dry_run=self.dry_run,
)
try:
proc = psutil.Process(pid)
action.details = f"Process: {proc.name()} (PID {pid})"
except psutil.NoSuchProcess:
action.details = f"PID {pid} no longer exists"
action.success = True
return action
if self.dry_run:
action.success = True
return action
try:
proc.kill()
proc.wait(timeout=5)
action.success = True
logger.info("Killed process %d (%s)", pid, proc.name())
except psutil.NoSuchProcess:
action.success = True
action.details += " (already exited)"
except (psutil.AccessDenied, psutil.TimeoutExpired) as exc:
action.details += f"{exc}"
logger.error("Failed to kill PID %d: %s", pid, exc)
return action
# ------------------------------------------------------------------
# Cron cleanup
# ------------------------------------------------------------------
def remove_malicious_cron(self, pattern: Optional[str] = None) -> RemediationAction:
"""Remove cron entries matching suspicious patterns.
If *pattern* is ``None``, uses all :pydata:`SUSPICIOUS_CRON_PATTERNS`.
"""
action = RemediationAction(
action="remove_malicious_cron",
target="/var/spool/cron + /etc/cron.d",
dry_run=self.dry_run,
)
patterns = [re.compile(pattern)] if pattern else [
re.compile(p) for p in SUSPICIOUS_CRON_PATTERNS
]
removed_lines: List[str] = []
cron_dirs = [
Path("/var/spool/cron/crontabs"),
Path("/var/spool/cron"),
Path("/etc/cron.d"),
]
for cron_dir in cron_dirs:
if not cron_dir.is_dir():
continue
for cron_file in cron_dir.iterdir():
if not cron_file.is_file():
continue
try:
lines = cron_file.read_text().splitlines()
clean_lines = []
for line in lines:
if any(pat.search(line) for pat in patterns):
removed_lines.append(f"{cron_file}: {line.strip()}")
else:
clean_lines.append(line)
if len(clean_lines) < len(lines) and not self.dry_run:
cron_file.write_text("\n".join(clean_lines) + "\n")
except OSError:
continue
action.details = f"Removed {len(removed_lines)} cron line(s)"
if removed_lines:
action.details += ": " + "; ".join(removed_lines[:5])
action.success = True
logger.info("remove_malicious_cron: %s", action.details)
return action
# ------------------------------------------------------------------
# SSH key cleanup
# ------------------------------------------------------------------
def clean_authorized_keys(self, path: Optional[str | Path] = None) -> RemediationAction:
"""Remove unauthorized keys from ``authorized_keys``.
Without *path*, scans all users' ``~/.ssh/authorized_keys`` plus
``/root/.ssh/authorized_keys``.
In non-dry-run mode, backs up the file before modifying.
"""
action = RemediationAction(
action="clean_authorized_keys",
target=str(path) if path else "all users",
dry_run=self.dry_run,
)
targets: List[Path] = []
if path:
targets.append(Path(path))
else:
# Root
root_ak = Path("/root/.ssh/authorized_keys")
if root_ak.exists():
targets.append(root_ak)
# System users from /home
home = Path("/home")
if home.is_dir():
for user_dir in home.iterdir():
ak = user_dir / ".ssh" / "authorized_keys"
if ak.exists():
targets.append(ak)
total_removed = 0
for ak_path in targets:
try:
lines = ak_path.read_text().splitlines()
clean: List[str] = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
clean.append(line)
continue
# Flag lines with forced commands as suspicious.
if stripped.startswith("command="):
total_removed += 1
continue
clean.append(line)
if len(clean) < len(lines) and not self.dry_run:
backup = ak_path.with_suffix(".bak")
shutil.copy2(str(ak_path), str(backup))
ak_path.write_text("\n".join(clean) + "\n")
except OSError:
continue
action.details = f"Removed {total_removed} suspicious key(s) from {len(targets)} file(s)"
action.success = True
logger.info("clean_authorized_keys: %s", action.details)
return action
# ------------------------------------------------------------------
# Startup script cleanup
# ------------------------------------------------------------------
def remove_suspicious_startup(self, path: Optional[str | Path] = None) -> RemediationAction:
"""Remove suspicious entries from init scripts, systemd units, or rc.local."""
action = RemediationAction(
action="remove_suspicious_startup",
target=str(path) if path else "/etc/init.d, systemd, rc.local",
dry_run=self.dry_run,
)
suspicious_re = re.compile(
r"(?:curl|wget)\s+.*\|\s*(?:sh|bash)|xmrig|minerd|/dev/tcp/|nohup\s+.*&",
re.IGNORECASE,
)
targets: List[Path] = []
if path:
targets.append(Path(path))
else:
rc_local = Path("/etc/rc.local")
if rc_local.exists():
targets.append(rc_local)
for d in ("/etc/init.d", "/etc/systemd/system"):
dp = Path(d)
if dp.is_dir():
targets.extend(f for f in dp.iterdir() if f.is_file())
cleaned_count = 0
for target in targets:
try:
content = target.read_text()
lines = content.splitlines()
clean = [l for l in lines if not suspicious_re.search(l)]
if len(clean) < len(lines):
cleaned_count += len(lines) - len(clean)
if not self.dry_run:
backup = target.with_suffix(target.suffix + ".bak")
shutil.copy2(str(target), str(backup))
target.write_text("\n".join(clean) + "\n")
except OSError:
continue
action.details = f"Removed {cleaned_count} suspicious line(s) from {len(targets)} file(s)"
action.success = True
logger.info("remove_suspicious_startup: %s", action.details)
return action
# ------------------------------------------------------------------
# LD_PRELOAD cleanup
# ------------------------------------------------------------------
def fix_ld_preload(self) -> RemediationAction:
"""Remove all entries from ``/etc/ld.so.preload``."""
action = RemediationAction(
action="fix_ld_preload",
target="/etc/ld.so.preload",
dry_run=self.dry_run,
)
ld_path = Path("/etc/ld.so.preload")
if not ld_path.exists():
action.details = "File does not exist — nothing to fix"
action.success = True
return action
try:
content = ld_path.read_text().strip()
if not content:
action.details = "File is already empty"
action.success = True
return action
action.details = f"Clearing ld.so.preload (was: {content[:120]})"
if not self.dry_run:
backup = ld_path.with_suffix(".bak")
shutil.copy2(str(ld_path), str(backup))
ld_path.write_text("")
action.success = True
logger.info("fix_ld_preload: %s", action.details)
except OSError as exc:
action.details = f"Failed: {exc}"
logger.error("fix_ld_preload: %s", exc)
return action
# ------------------------------------------------------------------
# Network blocking
# ------------------------------------------------------------------
def block_ip(self, ip_address: str) -> RemediationAction:
"""Add an iptables DROP rule for *ip_address*."""
action = RemediationAction(
action="block_ip",
target=ip_address,
dry_run=self.dry_run,
)
cmd = ["iptables", "-A", "OUTPUT", "-d", ip_address, "-j", "DROP"]
action.details = f"Rule: {' '.join(cmd)}"
if self.dry_run:
action.success = True
return action
try:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10)
action.success = True
logger.info("Blocked IP via iptables: %s", ip_address)
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc:
action.details += f" — failed: {exc}"
logger.error("Failed to block IP %s: %s", ip_address, exc)
return action
def block_domain(self, domain: str) -> RemediationAction:
"""Redirect *domain* to 127.0.0.1 via ``/etc/hosts``."""
action = RemediationAction(
action="block_domain",
target=domain,
dry_run=self.dry_run,
)
hosts_path = Path("/etc/hosts")
entry = f"127.0.0.1 {domain} # blocked by ayn-antivirus"
action.details = f"Adding to /etc/hosts: {entry}"
if self.dry_run:
action.success = True
return action
try:
current = hosts_path.read_text()
if domain in current:
action.details = f"Domain {domain} already in /etc/hosts"
action.success = True
return action
with open(hosts_path, "a") as fh:
fh.write(f"\n{entry}\n")
action.success = True
logger.info("Blocked domain via /etc/hosts: %s", domain)
except OSError as exc:
action.details += f" — failed: {exc}"
logger.error("Failed to block domain %s: %s", domain, exc)
return action
# ------------------------------------------------------------------
# System binary restoration
# ------------------------------------------------------------------
def restore_system_binary(self, binary_path: str | Path) -> RemediationAction:
"""Reinstall the package owning *binary_path* using the system package manager."""
binary_path = Path(binary_path)
action = RemediationAction(
action="restore_system_binary",
target=str(binary_path),
dry_run=self.dry_run,
)
# Determine package manager and owning package.
pkg_name, pm_cmd = _find_owning_package(binary_path)
if not pkg_name:
action.details = f"Cannot determine owning package for {binary_path}"
return action
reinstall_cmd = pm_cmd + [pkg_name]
action.details = f"Reinstalling package '{pkg_name}': {' '.join(reinstall_cmd)}"
if self.dry_run:
action.success = True
return action
try:
subprocess.check_call(
reinstall_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120
)
action.success = True
logger.info("Restored %s via %s", binary_path, " ".join(reinstall_cmd))
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc:
action.details += f" — failed: {exc}"
logger.error("Failed to restore %s: %s", binary_path, exc)
return action
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _publish(self, action: RemediationAction) -> None:
event_bus.publish(EventType.REMEDIATION_ACTION, {
"action": action.action,
"target": action.target,
"details": action.details,
"success": action.success,
"dry_run": action.dry_run,
})
# ---------------------------------------------------------------------------
# Package-manager helpers
# ---------------------------------------------------------------------------
def _find_owning_package(binary_path: Path) -> tuple:
"""Return ``(package_name, reinstall_command_prefix)`` or ``("", [])``."""
path_str = str(binary_path)
# dpkg (Debian/Ubuntu)
try:
out = subprocess.check_output(
["dpkg", "-S", path_str], stderr=subprocess.DEVNULL, timeout=10
).decode().strip()
pkg = out.split(":")[0]
return pkg, ["apt-get", "install", "--reinstall", "-y"]
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
pass
# rpm (RHEL/CentOS/Fedora)
try:
out = subprocess.check_output(
["rpm", "-qf", path_str], stderr=subprocess.DEVNULL, timeout=10
).decode().strip()
if "not owned" not in out:
# Try dnf first, fall back to yum.
pm = "dnf" if shutil.which("dnf") else "yum"
return out, [pm, "reinstall", "-y"]
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
pass
return "", []
@@ -0,0 +1,535 @@
"""Report generator for AYN Antivirus.
Produces scan reports in plain-text, JSON, and HTML formats from
:class:`ScanResult` / :class:`FullScanResult` dataclasses.
"""
from __future__ import annotations
import html as html_mod
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ayn_antivirus import __version__
from ayn_antivirus.core.engine import (
FullScanResult,
ScanResult,
ThreatInfo,
)
from ayn_antivirus.utils.helpers import format_duration, format_size, get_system_info
# Type alias for either result kind.
AnyResult = Union[ScanResult, FullScanResult]
class ReportGenerator:
"""Create scan reports in multiple output formats."""
# ------------------------------------------------------------------
# Plain text
# ------------------------------------------------------------------
@staticmethod
def generate_text(result: AnyResult) -> str:
"""Render a human-readable plain-text report."""
threats, meta = _extract(result)
lines: List[str] = []
lines.append("=" * 72)
lines.append(" AYN ANTIVIRUS — SCAN REPORT")
lines.append("=" * 72)
lines.append("")
lines.append(f" Generated : {datetime.utcnow().isoformat()}")
lines.append(f" Version : {__version__}")
lines.append(f" Scan ID : {meta.get('scan_id', 'N/A')}")
lines.append(f" Scan Type : {meta.get('scan_type', 'N/A')}")
lines.append(f" Duration : {format_duration(meta.get('duration', 0))}")
lines.append("")
# Summary.
sev_counts = _severity_counts(threats)
lines.append("-" * 72)
lines.append(" SUMMARY")
lines.append("-" * 72)
lines.append(f" Files scanned : {meta.get('files_scanned', 0)}")
lines.append(f" Files skipped : {meta.get('files_skipped', 0)}")
lines.append(f" Threats found : {len(threats)}")
lines.append(f" CRITICAL : {sev_counts.get('CRITICAL', 0)}")
lines.append(f" HIGH : {sev_counts.get('HIGH', 0)}")
lines.append(f" MEDIUM : {sev_counts.get('MEDIUM', 0)}")
lines.append(f" LOW : {sev_counts.get('LOW', 0)}")
lines.append("")
# Threat table.
if threats:
lines.append("-" * 72)
lines.append(" THREATS")
lines.append("-" * 72)
hdr = f" {'#':>3} {'Severity':<10} {'Threat Name':<30} {'File'}"
lines.append(hdr)
lines.append(" " + "-" * 68)
for idx, t in enumerate(threats, 1):
sev = _sev_str(t)
name = t.threat_name[:30]
fpath = t.path[:60]
lines.append(f" {idx:>3} {sev:<10} {name:<30} {fpath}")
lines.append("")
# System info.
try:
info = get_system_info()
lines.append("-" * 72)
lines.append(" SYSTEM INFORMATION")
lines.append("-" * 72)
lines.append(f" Hostname : {info['hostname']}")
lines.append(f" OS : {info['os_pretty']}")
lines.append(f" CPUs : {info['cpu_count']}")
lines.append(f" Memory : {info['memory_total_human']}")
lines.append(f" Uptime : {info['uptime_human']}")
lines.append("")
except Exception:
pass
lines.append("=" * 72)
lines.append(f" Report generated by AYN Antivirus v{__version__}")
lines.append("=" * 72)
return "\n".join(lines) + "\n"
# ------------------------------------------------------------------
# JSON
# ------------------------------------------------------------------
@staticmethod
def generate_json(result: AnyResult) -> str:
"""Render a machine-readable JSON report."""
threats, meta = _extract(result)
sev_counts = _severity_counts(threats)
try:
sys_info = get_system_info()
except Exception:
sys_info = {}
report: Dict[str, Any] = {
"generator": f"ayn-antivirus v{__version__}",
"generated_at": datetime.utcnow().isoformat(),
"scan": {
"scan_id": meta.get("scan_id"),
"scan_type": meta.get("scan_type"),
"start_time": meta.get("start_time"),
"end_time": meta.get("end_time"),
"duration_seconds": meta.get("duration"),
"files_scanned": meta.get("files_scanned", 0),
"files_skipped": meta.get("files_skipped", 0),
},
"summary": {
"total_threats": len(threats),
"by_severity": sev_counts,
},
"threats": [
{
"path": t.path,
"threat_name": t.threat_name,
"threat_type": t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type),
"severity": _sev_str(t),
"detector": t.detector_name,
"details": t.details,
"file_hash": t.file_hash,
"timestamp": t.timestamp.isoformat() if hasattr(t.timestamp, "isoformat") else str(t.timestamp),
}
for t in threats
],
"system": sys_info,
}
return json.dumps(report, indent=2, default=str)
# ------------------------------------------------------------------
# HTML
# ------------------------------------------------------------------
@staticmethod
def generate_html(result: AnyResult) -> str:
"""Render a professional HTML report with dark-theme CSS."""
threats, meta = _extract(result)
sev_counts = _severity_counts(threats)
now = datetime.utcnow()
esc = html_mod.escape
try:
sys_info = get_system_info()
except Exception:
sys_info = {}
total_threats = len(threats)
status_class = "clean" if total_threats == 0 else "infected"
# --- Build threat table rows ---
threat_rows = []
for idx, t in enumerate(threats, 1):
sev = _sev_str(t)
sev_lower = sev.lower()
ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type)
threat_rows.append(
f"<tr>"
f'<td class="idx">{idx}</td>'
f"<td>{esc(t.path)}</td>"
f"<td>{esc(t.threat_name)}</td>"
f"<td>{esc(ttype)}</td>"
f'<td><span class="badge badge-{sev_lower}">{sev}</span></td>'
f"<td>{esc(t.detector_name)}</td>"
f'<td class="hash">{esc(t.file_hash[:16])}{"" if len(t.file_hash) > 16 else ""}</td>'
f"</tr>"
)
threat_table = "\n".join(threat_rows) if threat_rows else (
'<tr><td colspan="7" class="empty">No threats detected ✅</td></tr>'
)
# --- System info rows ---
sys_rows = ""
if sys_info:
sys_rows = (
f"<tr><td>Hostname</td><td>{esc(str(sys_info.get('hostname', '')))}</td></tr>"
f"<tr><td>Operating System</td><td>{esc(str(sys_info.get('os_pretty', '')))}</td></tr>"
f"<tr><td>Architecture</td><td>{esc(str(sys_info.get('architecture', '')))}</td></tr>"
f"<tr><td>CPUs</td><td>{sys_info.get('cpu_count', '?')}</td></tr>"
f"<tr><td>Memory</td><td>{esc(str(sys_info.get('memory_total_human', '')))}"
f" ({sys_info.get('memory_percent', '?')}% used)</td></tr>"
f"<tr><td>Uptime</td><td>{esc(str(sys_info.get('uptime_human', '')))}</td></tr>"
)
html = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AYN Antivirus Scan Report</title>
<style>
{_CSS}
</style>
</head>
<body>
<!-- Header -->
<header>
<div class="logo"> AYN ANTIVIRUS</div>
<div class="subtitle">Scan Report {esc(now.strftime("%Y-%m-%d %H:%M:%S"))}</div>
</header>
<!-- Summary cards -->
<section class="cards">
<div class="card">
<div class="card-value">{meta.get("files_scanned", 0)}</div>
<div class="card-label">Files Scanned</div>
</div>
<div class="card card-{status_class}">
<div class="card-value">{total_threats}</div>
<div class="card-label">Threats Found</div>
</div>
<div class="card card-critical">
<div class="card-value">{sev_counts.get("CRITICAL", 0)}</div>
<div class="card-label">Critical</div>
</div>
<div class="card card-high">
<div class="card-value">{sev_counts.get("HIGH", 0)}</div>
<div class="card-label">High</div>
</div>
<div class="card card-medium">
<div class="card-value">{sev_counts.get("MEDIUM", 0)}</div>
<div class="card-label">Medium</div>
</div>
<div class="card card-low">
<div class="card-value">{sev_counts.get("LOW", 0)}</div>
<div class="card-label">Low</div>
</div>
</section>
<!-- Scan details -->
<section class="details">
<h2>Scan Details</h2>
<table class="info-table">
<tr><td>Scan ID</td><td>{esc(str(meta.get("scan_id", "N/A")))}</td></tr>
<tr><td>Scan Type</td><td>{esc(str(meta.get("scan_type", "N/A")))}</td></tr>
<tr><td>Duration</td><td>{esc(format_duration(meta.get("duration", 0)))}</td></tr>
<tr><td>Files Scanned</td><td>{meta.get("files_scanned", 0)}</td></tr>
<tr><td>Files Skipped</td><td>{meta.get("files_skipped", 0)}</td></tr>
</table>
</section>
<!-- Threat table -->
<section class="threats">
<h2>Threat Details</h2>
<table class="threat-table">
<thead>
<tr>
<th>#</th>
<th>File Path</th>
<th>Threat Name</th>
<th>Type</th>
<th>Severity</th>
<th>Detector</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
{threat_table}
</tbody>
</table>
</section>
<!-- System info -->
<section class="system">
<h2>System Information</h2>
<table class="info-table">
{sys_rows}
</table>
</section>
<!-- Footer -->
<footer>
Generated by AYN Antivirus v{__version__} &mdash; {esc(now.isoformat())}
</footer>
</body>
</html>
"""
return html
# ------------------------------------------------------------------
# File output
# ------------------------------------------------------------------
@staticmethod
def save_report(content: str, filepath: str | Path) -> None:
"""Write *content* to *filepath*, creating parent dirs if needed."""
fp = Path(filepath)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content, encoding="utf-8")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract(result: AnyResult) -> tuple:
"""Return ``(threats_list, meta_dict)`` from either result type."""
if isinstance(result, FullScanResult):
sr = result.file_scan
threats = list(sr.threats)
elif isinstance(result, ScanResult):
sr = result
threats = list(sr.threats)
else:
sr = result
threats = []
meta: Dict[str, Any] = {
"scan_id": getattr(sr, "scan_id", None),
"scan_type": sr.scan_type.value if hasattr(sr, "scan_type") else None,
"start_time": sr.start_time.isoformat() if hasattr(sr, "start_time") and sr.start_time else None,
"end_time": sr.end_time.isoformat() if hasattr(sr, "end_time") and sr.end_time else None,
"duration": sr.duration_seconds if hasattr(sr, "duration_seconds") else 0,
"files_scanned": getattr(sr, "files_scanned", 0),
"files_skipped": getattr(sr, "files_skipped", 0),
}
return threats, meta
def _sev_str(threat: ThreatInfo) -> str:
"""Return the severity as an uppercase string."""
sev = threat.severity
if hasattr(sev, "name"):
return sev.name
return str(sev).upper()
def _severity_counts(threats: List[ThreatInfo]) -> Dict[str, int]:
counts: Dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
for t in threats:
key = _sev_str(t)
counts[key] = counts.get(key, 0) + 1
return counts
# ---------------------------------------------------------------------------
# Embedded CSS (dark theme)
# ---------------------------------------------------------------------------
_CSS = """\
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e0e0e0;
--text-dim: #8b8fa3;
--accent: #00bcd4;
--critical: #ff1744;
--high: #ff9100;
--medium: #ffea00;
--low: #00e676;
--clean: #00e676;
--infected: #ff1744;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 0;
}
header {
background: linear-gradient(135deg, #1a1d27 0%, #0d1117 100%);
border-bottom: 2px solid var(--accent);
text-align: center;
padding: 2rem 1rem;
}
header .logo {
font-size: 2rem;
font-weight: 800;
color: var(--accent);
letter-spacing: 0.1em;
}
header .subtitle {
color: var(--text-dim);
font-size: 0.95rem;
margin-top: 0.3rem;
}
section {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1.5rem;
}
h2 {
color: var(--accent);
font-size: 1.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.4rem;
}
/* Summary cards */
.cards {
display: flex;
flex-wrap: wrap;
gap: 1rem;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.card {
flex: 1 1 140px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2rem 1rem;
text-align: center;
}
.card-value {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
.card-label {
color: var(--text-dim);
font-size: 0.85rem;
margin-top: 0.2rem;
}
.card-clean .card-value { color: var(--clean); }
.card-infected .card-value { color: var(--infected); }
.card-critical .card-value { color: var(--critical); }
.card-high .card-value { color: var(--high); }
.card-medium .card-value { color: var(--medium); }
.card-low .card-value { color: var(--low); }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
.info-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.info-table td:first-child {
color: var(--text-dim);
width: 180px;
font-weight: 600;
}
.threat-table {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.threat-table thead th {
background: #12141c;
color: var(--accent);
padding: 0.7rem 0.75rem;
text-align: left;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.threat-table tbody td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
word-break: break-all;
}
.threat-table tbody tr:hover {
background: rgba(0, 188, 212, 0.06);
}
.threat-table .idx { color: var(--text-dim); width: 40px; }
.threat-table .hash { font-family: monospace; color: var(--text-dim); font-size: 0.8rem; }
.threat-table .empty { text-align: center; color: var(--clean); padding: 2rem; font-size: 1.1rem; }
/* Severity badges */
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 4px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-critical { background: rgba(255,23,68,0.15); color: var(--critical); border: 1px solid var(--critical); }
.badge-high { background: rgba(255,145,0,0.15); color: var(--high); border: 1px solid var(--high); }
.badge-medium { background: rgba(255,234,0,0.12); color: var(--medium); border: 1px solid var(--medium); }
.badge-low { background: rgba(0,230,118,0.12); color: var(--low); border: 1px solid var(--low); }
/* Footer */
footer {
text-align: center;
color: var(--text-dim);
font-size: 0.8rem;
padding: 2rem 1rem;
border-top: 1px solid var(--border);
margin-top: 3rem;
}
/* System info */
.system { margin-bottom: 2rem; }
"""
@@ -0,0 +1,17 @@
"""AYN Antivirus scanner modules."""
from ayn_antivirus.scanners.base import BaseScanner
from ayn_antivirus.scanners.container_scanner import ContainerScanner
from ayn_antivirus.scanners.file_scanner import FileScanner
from ayn_antivirus.scanners.memory_scanner import MemoryScanner
from ayn_antivirus.scanners.network_scanner import NetworkScanner
from ayn_antivirus.scanners.process_scanner import ProcessScanner
__all__ = [
"BaseScanner",
"ContainerScanner",
"FileScanner",
"MemoryScanner",
"NetworkScanner",
"ProcessScanner",
]
@@ -0,0 +1,58 @@
"""Abstract base class for all AYN scanners."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger(__name__)
class BaseScanner(ABC):
"""Common interface that every scanner module must implement.
Subclasses provide a ``scan`` method whose *target* argument type varies
by scanner (a file path, a PID, a network connection, etc.).
"""
# ------------------------------------------------------------------
# Identity
# ------------------------------------------------------------------
@property
@abstractmethod
def name(self) -> str:
"""Short, machine-friendly scanner identifier (e.g. ``"file_scanner"``)."""
...
@property
@abstractmethod
def description(self) -> str:
"""Human-readable one-liner describing what this scanner does."""
...
# ------------------------------------------------------------------
# Scanning
# ------------------------------------------------------------------
@abstractmethod
def scan(self, target: Any) -> Any:
"""Run the scanner against *target* and return a result object.
The concrete return type is defined by each subclass.
"""
...
# ------------------------------------------------------------------
# Helpers available to all subclasses
# ------------------------------------------------------------------
def _log_info(self, msg: str, *args: Any) -> None:
logger.info("[%s] " + msg, self.name, *args)
def _log_warning(self, msg: str, *args: Any) -> None:
logger.warning("[%s] " + msg, self.name, *args)
def _log_error(self, msg: str, *args: Any) -> None:
logger.error("[%s] " + msg, self.name, *args)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
"""File-system scanner for AYN Antivirus.
Walks directories, gathers file metadata, hashes files, and classifies
them by type (ELF binary, script, suspicious extension) so that downstream
detectors can focus on high-value targets.
"""
from __future__ import annotations
import grp
import logging
import os
import pwd
import stat
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional
from ayn_antivirus.constants import (
MAX_FILE_SIZE,
SUSPICIOUS_EXTENSIONS,
)
from ayn_antivirus.scanners.base import BaseScanner
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Well-known magic bytes
# ---------------------------------------------------------------------------
_ELF_MAGIC = b"\x7fELF"
_SCRIPT_SHEBANGS = (b"#!", b"#!/")
_PE_MAGIC = b"MZ"
class FileScanner(BaseScanner):
"""Enumerates, classifies, and hashes files on disk.
This scanner does **not** perform threat detection itself it prepares
the metadata that detectors (YARA, hash-lookup, heuristic) consume.
Parameters
----------
max_file_size:
Skip files larger than this (bytes). Defaults to
:pydata:`constants.MAX_FILE_SIZE`.
"""
def __init__(self, max_file_size: int = MAX_FILE_SIZE) -> None:
self.max_file_size = max_file_size
# ------------------------------------------------------------------
# BaseScanner interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "file_scanner"
@property
def description(self) -> str:
return "Enumerates and classifies files on disk"
def scan(self, target: Any) -> Dict[str, Any]:
"""Scan a single file and return its metadata + hash.
Parameters
----------
target:
A path (``str`` or ``Path``) to the file.
Returns
-------
dict
Keys: ``path``, ``size``, ``hash``, ``is_elf``, ``is_script``,
``suspicious_ext``, ``info``, ``header``, ``error``.
"""
filepath = Path(target)
result: Dict[str, Any] = {
"path": str(filepath),
"size": 0,
"hash": "",
"is_elf": False,
"is_script": False,
"suspicious_ext": False,
"info": {},
"header": b"",
"error": None,
}
try:
info = self.get_file_info(filepath)
result["info"] = info
result["size"] = info.get("size", 0)
except OSError as exc:
result["error"] = str(exc)
return result
if result["size"] > self.max_file_size:
result["error"] = f"Exceeds max size ({result['size']} > {self.max_file_size})"
return result
try:
result["hash"] = self.compute_hash(filepath)
except OSError as exc:
result["error"] = f"Hash failed: {exc}"
return result
try:
result["header"] = self.read_file_header(filepath)
except OSError:
pass # non-fatal
result["is_elf"] = self.is_elf_binary(filepath)
result["is_script"] = self.is_script(filepath)
result["suspicious_ext"] = self.is_suspicious_extension(filepath)
return result
# ------------------------------------------------------------------
# Directory walking
# ------------------------------------------------------------------
@staticmethod
def walk_directory(
path: str | Path,
recursive: bool = True,
exclude_patterns: Optional[List[str]] = None,
) -> Generator[Path, None, None]:
"""Yield every regular file under *path*.
Parameters
----------
path:
Root directory to walk.
recursive:
If ``False``, only yield files in the top-level directory.
exclude_patterns:
Path prefixes or glob-style patterns to skip. A file is skipped
if its absolute path starts with any pattern string.
"""
root = Path(path).resolve()
exclude = [str(Path(p).resolve()) for p in (exclude_patterns or [])]
if root.is_file():
yield root
return
iterator = root.rglob("*") if recursive else root.iterdir()
try:
for entry in iterator:
if not entry.is_file():
continue
entry_str = str(entry)
if any(entry_str.startswith(ex) for ex in exclude):
continue
yield entry
except PermissionError:
logger.warning("Permission denied walking: %s", root)
# ------------------------------------------------------------------
# File metadata
# ------------------------------------------------------------------
@staticmethod
def get_file_info(path: str | Path) -> Dict[str, Any]:
"""Return a metadata dict for the file at *path*.
Keys
----
size, permissions, permissions_octal, owner, group, modified_time,
created_time, is_symlink, is_suid, is_sgid.
Raises
------
OSError
If the file cannot be stat'd.
"""
p = Path(path)
st = p.stat()
mode = st.st_mode
# Owner / group — fall back gracefully on systems without the user.
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except (KeyError, ImportError):
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except (KeyError, ImportError):
group = str(st.st_gid)
return {
"size": st.st_size,
"permissions": stat.filemode(mode),
"permissions_octal": oct(mode & 0o7777),
"owner": owner,
"group": group,
"modified_time": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
"created_time": datetime.utcfromtimestamp(st.st_ctime).isoformat(),
"is_symlink": p.is_symlink(),
"is_suid": bool(mode & stat.S_ISUID),
"is_sgid": bool(mode & stat.S_ISGID),
}
# ------------------------------------------------------------------
# Hashing
# ------------------------------------------------------------------
@staticmethod
def compute_hash(path: str | Path, algorithm: str = "sha256") -> str:
"""Compute file hash. Delegates to canonical implementation."""
from ayn_antivirus.utils.helpers import hash_file
return hash_file(str(path), algo=algorithm)
# ------------------------------------------------------------------
# Header / magic number
# ------------------------------------------------------------------
@staticmethod
def read_file_header(path: str | Path, size: int = 8192) -> bytes:
"""Read the first *size* bytes of a file (for magic-number checks).
Raises
------
OSError
If the file cannot be opened.
"""
with open(path, "rb") as fh:
return fh.read(size)
# ------------------------------------------------------------------
# Type classification
# ------------------------------------------------------------------
@staticmethod
def is_elf_binary(path: str | Path) -> bool:
"""Return ``True`` if *path* begins with the ELF magic number."""
try:
with open(path, "rb") as fh:
return fh.read(4) == _ELF_MAGIC
except OSError:
return False
@staticmethod
def is_script(path: str | Path) -> bool:
"""Return ``True`` if *path* starts with a shebang (``#!``)."""
try:
with open(path, "rb") as fh:
head = fh.read(3)
return any(head.startswith(s) for s in _SCRIPT_SHEBANGS)
except OSError:
return False
@staticmethod
def is_suspicious_extension(path: str | Path) -> bool:
"""Return ``True`` if the file suffix is in :pydata:`SUSPICIOUS_EXTENSIONS`."""
return Path(path).suffix.lower() in SUSPICIOUS_EXTENSIONS
@@ -0,0 +1,332 @@
"""Process memory scanner for AYN Antivirus.
Reads ``/proc/<pid>/maps`` and ``/proc/<pid>/mem`` on Linux to search for
injected code, suspicious byte patterns (mining pool URLs, known malware
strings), and anomalous RWX memory regions.
Most operations require **root** privileges. On non-Linux systems the
scanner gracefully returns empty results.
"""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from ayn_antivirus.constants import CRYPTO_POOL_DOMAINS
from ayn_antivirus.scanners.base import BaseScanner
logger = logging.getLogger(__name__)
# Default byte-level patterns to search for in process memory.
_DEFAULT_PATTERNS: List[bytes] = [
# Mining pool URLs
*(domain.encode() for domain in CRYPTO_POOL_DOMAINS),
# Common miner stratum strings
b"stratum+tcp://",
b"stratum+ssl://",
b"stratum2+tcp://",
# Suspicious shell commands sometimes found in injected memory
b"/bin/sh -c",
b"/bin/bash -i",
b"/dev/tcp/",
# Known malware markers
b"PAYLOAD_START",
b"x86_64-linux-gnu",
b"ELF\x02\x01\x01",
]
# Size of chunks when reading /proc/<pid>/mem.
_MEM_READ_CHUNK = 65536
# Regex to parse a single line from /proc/<pid>/maps.
# address perms offset dev inode pathname
# 7f1c2a000000-7f1c2a021000 rw-p 00000000 00:00 0 [heap]
_MAPS_RE = re.compile(
r"^([0-9a-f]+)-([0-9a-f]+)\s+(r[w-][x-][ps-])\s+\S+\s+\S+\s+\d+\s*(.*)",
re.MULTILINE,
)
class MemoryScanner(BaseScanner):
"""Scan process memory for injected code and suspicious patterns.
.. note::
This scanner only works on Linux where ``/proc`` is available.
Operations on ``/proc/<pid>/mem`` typically require root or
``CAP_SYS_PTRACE``.
"""
# ------------------------------------------------------------------
# BaseScanner interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "memory_scanner"
@property
def description(self) -> str:
return "Scans process memory for injected code and malicious patterns"
def scan(self, target: Any) -> Dict[str, Any]:
"""Scan a single process by PID.
Parameters
----------
target:
The PID (``int``) of the process to inspect.
Returns
-------
dict
``pid``, ``rwx_regions``, ``pattern_matches``, ``strings_sample``,
``error``.
"""
pid = int(target)
result: Dict[str, Any] = {
"pid": pid,
"rwx_regions": [],
"pattern_matches": [],
"strings_sample": [],
"error": None,
}
if not Path("/proc").is_dir():
result["error"] = "Not a Linux system — /proc not available"
return result
try:
result["rwx_regions"] = self.find_injected_code(pid)
result["pattern_matches"] = self.scan_for_patterns(pid, _DEFAULT_PATTERNS)
result["strings_sample"] = self.get_memory_strings(pid, min_length=8)[:200]
except PermissionError:
result["error"] = f"Permission denied reading /proc/{pid}/mem (need root)"
except FileNotFoundError:
result["error"] = f"Process {pid} no longer exists"
except Exception as exc:
result["error"] = str(exc)
logger.exception("Error scanning memory for PID %d", pid)
return result
# ------------------------------------------------------------------
# /proc/<pid>/maps parsing
# ------------------------------------------------------------------
@staticmethod
def _read_maps(pid: int) -> List[Dict[str, Any]]:
"""Parse ``/proc/<pid>/maps`` and return a list of memory regions.
Each dict contains ``start`` (int), ``end`` (int), ``perms`` (str),
``pathname`` (str).
Raises
------
FileNotFoundError
If the process does not exist.
PermissionError
If the caller cannot read the maps file.
"""
maps_path = Path(f"/proc/{pid}/maps")
content = maps_path.read_text()
regions: List[Dict[str, Any]] = []
for match in _MAPS_RE.finditer(content):
regions.append({
"start": int(match.group(1), 16),
"end": int(match.group(2), 16),
"perms": match.group(3),
"pathname": match.group(4).strip(),
})
return regions
# ------------------------------------------------------------------
# Memory reading helper
# ------------------------------------------------------------------
@staticmethod
def _read_region(pid: int, start: int, end: int) -> bytes:
"""Read bytes from ``/proc/<pid>/mem`` between *start* and *end*.
Returns as many bytes as could be read; silently returns partial
data if parts of the region are not readable.
"""
mem_path = f"/proc/{pid}/mem"
data = bytearray()
try:
fd = os.open(mem_path, os.O_RDONLY)
try:
os.lseek(fd, start, os.SEEK_SET)
remaining = end - start
while remaining > 0:
chunk_size = min(_MEM_READ_CHUNK, remaining)
try:
chunk = os.read(fd, chunk_size)
except OSError:
break
if not chunk:
break
data.extend(chunk)
remaining -= len(chunk)
finally:
os.close(fd)
except OSError:
pass # region may be unmapped by the time we read
return bytes(data)
# ------------------------------------------------------------------
# Public scanning methods
# ------------------------------------------------------------------
def scan_process_memory(self, pid: int) -> List[Dict[str, Any]]:
"""Scan all readable regions of a process's address space.
Returns a list of dicts, one per region, containing ``start``,
``end``, ``perms``, ``pathname``, and a boolean ``has_suspicious``
flag set when default patterns are found.
Raises
------
PermissionError, FileNotFoundError
"""
regions = self._read_maps(pid)
results: List[Dict[str, Any]] = []
for region in regions:
# Only read regions that are at least readable.
if not region["perms"].startswith("r"):
continue
size = region["end"] - region["start"]
if size > 50 * 1024 * 1024:
continue # skip very large regions to avoid OOM
data = self._read_region(pid, region["start"], region["end"])
has_suspicious = any(pat in data for pat in _DEFAULT_PATTERNS)
results.append({
"start": hex(region["start"]),
"end": hex(region["end"]),
"perms": region["perms"],
"pathname": region["pathname"],
"size": size,
"has_suspicious": has_suspicious,
})
return results
def find_injected_code(self, pid: int) -> List[Dict[str, Any]]:
"""Find memory regions with **RWX** (read-write-execute) permissions.
Legitimate applications rarely need RWX regions. Their presence may
indicate code injection, JIT shellcode, or a packed/encrypted payload
that has been unpacked at runtime.
Returns a list of dicts with ``start``, ``end``, ``perms``,
``pathname``, ``size``.
"""
regions = self._read_maps(pid)
rwx: List[Dict[str, Any]] = []
for region in regions:
perms = region["perms"]
# RWX = positions: r(0) w(1) x(2)
if len(perms) >= 3 and perms[0] == "r" and perms[1] == "w" and perms[2] == "x":
size = region["end"] - region["start"]
rwx.append({
"start": hex(region["start"]),
"end": hex(region["end"]),
"perms": perms,
"pathname": region["pathname"],
"size": size,
"severity": "HIGH",
"reason": f"RWX region ({size} bytes) — possible code injection",
})
return rwx
def get_memory_strings(
self,
pid: int,
min_length: int = 6,
) -> List[str]:
"""Extract printable ASCII strings from readable memory regions.
Parameters
----------
min_length:
Minimum string length to keep.
Returns a list of decoded strings (capped at 500 chars each).
"""
regions = self._read_maps(pid)
strings: List[str] = []
printable_re = re.compile(rb"[\x20-\x7e]{%d,}" % min_length)
for region in regions:
if not region["perms"].startswith("r"):
continue
size = region["end"] - region["start"]
if size > 10 * 1024 * 1024:
continue # skip huge regions
data = self._read_region(pid, region["start"], region["end"])
for match in printable_re.finditer(data):
s = match.group().decode("ascii", errors="replace")
strings.append(s[:500])
# Cap total to avoid unbounded memory usage.
if len(strings) >= 10_000:
return strings
return strings
def scan_for_patterns(
self,
pid: int,
patterns: Optional[Sequence[bytes]] = None,
) -> List[Dict[str, Any]]:
"""Search process memory for specific byte patterns.
Parameters
----------
patterns:
Byte strings to search for. Defaults to
:pydata:`_DEFAULT_PATTERNS` (mining pool URLs, stratum prefixes,
shell commands).
Returns a list of dicts with ``pattern``, ``region_start``,
``region_perms``, ``offset``.
"""
if patterns is None:
patterns = _DEFAULT_PATTERNS
regions = self._read_maps(pid)
matches: List[Dict[str, Any]] = []
for region in regions:
if not region["perms"].startswith("r"):
continue
size = region["end"] - region["start"]
if size > 50 * 1024 * 1024:
continue
data = self._read_region(pid, region["start"], region["end"])
for pat in patterns:
idx = data.find(pat)
if idx != -1:
matches.append({
"pattern": pat.decode("utf-8", errors="replace"),
"region_start": hex(region["start"]),
"region_perms": region["perms"],
"region_pathname": region["pathname"],
"offset": idx,
"severity": "HIGH",
"reason": f"Suspicious pattern found in memory: {pat[:60]!r}",
})
return matches
@@ -0,0 +1,328 @@
"""Network scanner for AYN Antivirus.
Inspects active TCP/UDP connections for traffic to known mining pools,
suspicious ports, and unexpected listening services. Also audits
``/etc/resolv.conf`` for DNS hijacking indicators.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import psutil
from ayn_antivirus.constants import (
CRYPTO_POOL_DOMAINS,
SUSPICIOUS_PORTS,
)
from ayn_antivirus.scanners.base import BaseScanner
logger = logging.getLogger(__name__)
# Well-known system services that are *expected* to listen — extend as needed.
_EXPECTED_LISTENERS = {
22: "sshd",
53: "systemd-resolved",
80: "nginx",
443: "nginx",
3306: "mysqld",
5432: "postgres",
6379: "redis-server",
8080: "java",
}
# Known-malicious / suspicious public DNS servers sometimes injected by
# malware into resolv.conf to redirect DNS queries.
_SUSPICIOUS_DNS_SERVERS = [
"8.8.4.4", # not inherently bad, but worth noting if unexpected
"1.0.0.1",
"208.67.222.123",
"198.54.117.10",
"77.88.8.7",
"94.140.14.14",
]
class NetworkScanner(BaseScanner):
"""Scan active network connections for suspicious activity.
Wraps :func:`psutil.net_connections` and enriches each connection with
process ownership and threat classification.
"""
# ------------------------------------------------------------------
# BaseScanner interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "network_scanner"
@property
def description(self) -> str:
return "Inspects network connections for mining pools and suspicious ports"
def scan(self, target: Any = None) -> Dict[str, Any]:
"""Run a full network scan.
*target* is ignored all connections are inspected.
Returns
-------
dict
``total``, ``suspicious``, ``unexpected_listeners``, ``dns_issues``.
"""
all_conns = self.get_all_connections()
suspicious = self.find_suspicious_connections()
listeners = self.check_listening_ports()
dns = self.check_dns_queries()
return {
"total": len(all_conns),
"suspicious": suspicious,
"unexpected_listeners": listeners,
"dns_issues": dns,
}
# ------------------------------------------------------------------
# Connection enumeration
# ------------------------------------------------------------------
@staticmethod
def get_all_connections() -> List[Dict[str, Any]]:
"""Return a snapshot of every inet connection.
Each dict contains: ``fd``, ``family``, ``type``, ``local_addr``,
``remote_addr``, ``status``, ``pid``, ``process_name``.
"""
result: List[Dict[str, Any]] = []
try:
connections = psutil.net_connections(kind="inet")
except psutil.AccessDenied:
logger.warning("Insufficient permissions to read network connections")
return result
for conn in connections:
local = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else ""
remote = f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else ""
proc_name = ""
if conn.pid:
try:
proc_name = psutil.Process(conn.pid).name()
except (psutil.NoSuchProcess, psutil.AccessDenied):
proc_name = "?"
result.append({
"fd": conn.fd,
"family": str(conn.family),
"type": str(conn.type),
"local_addr": local,
"remote_addr": remote,
"status": conn.status,
"pid": conn.pid,
"process_name": proc_name,
})
return result
# ------------------------------------------------------------------
# Suspicious-connection detection
# ------------------------------------------------------------------
def find_suspicious_connections(self) -> List[Dict[str, Any]]:
"""Identify connections to known mining pools or suspicious ports.
Checks remote addresses against :pydata:`constants.CRYPTO_POOL_DOMAINS`
and :pydata:`constants.SUSPICIOUS_PORTS`.
"""
suspicious: List[Dict[str, Any]] = []
try:
connections = psutil.net_connections(kind="inet")
except psutil.AccessDenied:
logger.warning("Insufficient permissions to read network connections")
return suspicious
for conn in connections:
raddr = conn.raddr
if not raddr:
continue
remote_ip = raddr.ip
remote_port = raddr.port
local_str = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else "?"
remote_str = f"{remote_ip}:{remote_port}"
proc_info = self.resolve_process_for_connection(conn)
# Suspicious port.
if remote_port in SUSPICIOUS_PORTS:
suspicious.append({
"local_addr": local_str,
"remote_addr": remote_str,
"pid": conn.pid,
"process": proc_info,
"status": conn.status,
"reason": f"Connection on known mining port {remote_port}",
"severity": "HIGH",
})
# Mining-pool domain (substring match on IP / hostname).
for domain in CRYPTO_POOL_DOMAINS:
if domain in remote_ip:
suspicious.append({
"local_addr": local_str,
"remote_addr": remote_str,
"pid": conn.pid,
"process": proc_info,
"status": conn.status,
"reason": f"Connection to known mining pool: {domain}",
"severity": "CRITICAL",
})
break
return suspicious
# ------------------------------------------------------------------
# Listening-port audit
# ------------------------------------------------------------------
@staticmethod
def check_listening_ports() -> List[Dict[str, Any]]:
"""Return listening sockets that are *not* in the expected-services list.
Unexpected listeners may indicate a backdoor or reverse shell.
"""
unexpected: List[Dict[str, Any]] = []
try:
connections = psutil.net_connections(kind="inet")
except psutil.AccessDenied:
logger.warning("Insufficient permissions to read network connections")
return unexpected
for conn in connections:
if conn.status != "LISTEN":
continue
port = conn.laddr.port if conn.laddr else None
if port is None:
continue
proc_name = ""
if conn.pid:
try:
proc_name = psutil.Process(conn.pid).name()
except (psutil.NoSuchProcess, psutil.AccessDenied):
proc_name = "?"
expected_name = _EXPECTED_LISTENERS.get(port)
if expected_name and expected_name in proc_name:
continue # known good
# Skip very common ephemeral / system ports when we can't resolve.
if port > 49152:
continue
if port not in _EXPECTED_LISTENERS:
unexpected.append({
"port": port,
"local_addr": f"{conn.laddr.ip}:{port}" if conn.laddr else f"?:{port}",
"pid": conn.pid,
"process_name": proc_name,
"reason": f"Unexpected listening service on port {port}",
"severity": "MEDIUM",
})
return unexpected
# ------------------------------------------------------------------
# Process resolution
# ------------------------------------------------------------------
@staticmethod
def resolve_process_for_connection(conn: Any) -> Dict[str, Any]:
"""Return basic process info for a ``psutil`` connection object.
Returns
-------
dict
``pid``, ``name``, ``cmdline``, ``username``.
"""
info: Dict[str, Any] = {
"pid": conn.pid,
"name": "",
"cmdline": [],
"username": "",
}
if not conn.pid:
return info
try:
proc = psutil.Process(conn.pid)
info["name"] = proc.name()
info["cmdline"] = proc.cmdline()
info["username"] = proc.username()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return info
# ------------------------------------------------------------------
# DNS audit
# ------------------------------------------------------------------
@staticmethod
def check_dns_queries() -> List[Dict[str, Any]]:
"""Audit ``/etc/resolv.conf`` for suspicious DNS server entries.
Malware sometimes rewrites ``resolv.conf`` to redirect DNS through an
attacker-controlled resolver, enabling man-in-the-middle attacks or
DNS-based C2 communication.
"""
issues: List[Dict[str, Any]] = []
resolv_path = Path("/etc/resolv.conf")
if not resolv_path.exists():
return issues
try:
content = resolv_path.read_text()
except PermissionError:
logger.warning("Cannot read /etc/resolv.conf")
return issues
nameserver_re = re.compile(r"^\s*nameserver\s+(\S+)", re.MULTILINE)
for match in nameserver_re.finditer(content):
server = match.group(1)
if server in _SUSPICIOUS_DNS_SERVERS:
issues.append({
"server": server,
"file": str(resolv_path),
"reason": f"Potentially suspicious DNS server: {server}",
"severity": "MEDIUM",
})
# Flag non-RFC1918 / non-loopback servers that look unusual.
if not (
server.startswith("127.")
or server.startswith("10.")
or server.startswith("192.168.")
or server.startswith("172.")
or server == "::1"
):
# External DNS — not inherently bad but worth logging if the
# admin didn't set it intentionally.
issues.append({
"server": server,
"file": str(resolv_path),
"reason": f"External DNS server configured: {server}",
"severity": "LOW",
})
return issues
@@ -0,0 +1,387 @@
"""Process scanner for AYN Antivirus.
Inspects running processes for known crypto-miners, anomalous CPU usage,
and hidden / stealth processes. Uses ``psutil`` for cross-platform process
enumeration and ``/proc`` on Linux for hidden-process detection.
"""
from __future__ import annotations
import logging
import os
import signal
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import psutil
from ayn_antivirus.constants import (
CRYPTO_MINER_PROCESS_NAMES,
HIGH_CPU_THRESHOLD,
)
from ayn_antivirus.scanners.base import BaseScanner
logger = logging.getLogger(__name__)
class ProcessScanner(BaseScanner):
"""Scan running processes for malware, miners, and anomalies.
Parameters
----------
cpu_threshold:
CPU-usage percentage above which a process is flagged. Defaults to
:pydata:`constants.HIGH_CPU_THRESHOLD`.
"""
def __init__(self, cpu_threshold: float = HIGH_CPU_THRESHOLD) -> None:
self.cpu_threshold = cpu_threshold
# ------------------------------------------------------------------
# BaseScanner interface
# ------------------------------------------------------------------
@property
def name(self) -> str:
return "process_scanner"
@property
def description(self) -> str:
return "Inspects running processes for miners and suspicious activity"
def scan(self, target: Any = None) -> Dict[str, Any]:
"""Run a full process scan.
*target* is ignored all live processes are inspected.
Returns
-------
dict
``total``, ``suspicious``, ``high_cpu``, ``hidden``.
"""
all_procs = self.get_all_processes()
suspicious = self.find_suspicious_processes()
high_cpu = self.find_high_cpu_processes()
hidden = self.find_hidden_processes()
return {
"total": len(all_procs),
"suspicious": suspicious,
"high_cpu": high_cpu,
"hidden": hidden,
}
# ------------------------------------------------------------------
# Process enumeration
# ------------------------------------------------------------------
@staticmethod
def get_all_processes() -> List[Dict[str, Any]]:
"""Return a snapshot of every running process.
Each dict contains: ``pid``, ``name``, ``cmdline``, ``cpu_percent``,
``memory_percent``, ``username``, ``create_time``, ``connections``,
``open_files``.
"""
result: List[Dict[str, Any]] = []
attrs = [
"pid", "name", "cmdline", "cpu_percent",
"memory_percent", "username", "create_time",
]
for proc in psutil.process_iter(attrs):
try:
info = proc.info
# Connections and open files are expensive; fetch lazily.
try:
connections = [
{
"fd": c.fd,
"family": str(c.family),
"type": str(c.type),
"laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "",
"raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "",
"status": c.status,
}
for c in proc.net_connections()
]
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
connections = []
try:
open_files = [f.path for f in proc.open_files()]
except (psutil.AccessDenied, psutil.NoSuchProcess, OSError):
open_files = []
create_time = info.get("create_time")
result.append({
"pid": info["pid"],
"name": info.get("name", ""),
"cmdline": info.get("cmdline") or [],
"cpu_percent": info.get("cpu_percent") or 0.0,
"memory_percent": info.get("memory_percent") or 0.0,
"username": info.get("username", "?"),
"create_time": (
datetime.utcfromtimestamp(create_time).isoformat()
if create_time
else None
),
"connections": connections,
"open_files": open_files,
})
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return result
# ------------------------------------------------------------------
# Suspicious-process detection
# ------------------------------------------------------------------
def find_suspicious_processes(self) -> List[Dict[str, Any]]:
"""Return processes whose name or command line matches a known miner.
Matches are case-insensitive against
:pydata:`constants.CRYPTO_MINER_PROCESS_NAMES`.
"""
suspicious: List[Dict[str, Any]] = []
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]):
try:
info = proc.info
pname = (info.get("name") or "").lower()
cmdline = " ".join(info.get("cmdline") or []).lower()
for miner in CRYPTO_MINER_PROCESS_NAMES:
if miner in pname or miner in cmdline:
suspicious.append({
"pid": info["pid"],
"name": info.get("name", ""),
"cmdline": info.get("cmdline") or [],
"cpu_percent": info.get("cpu_percent") or 0.0,
"username": info.get("username", "?"),
"matched_signature": miner,
"reason": f"Known miner process: {miner}",
"severity": "CRITICAL",
})
break # one match per process
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return suspicious
# ------------------------------------------------------------------
# High-CPU detection
# ------------------------------------------------------------------
def find_high_cpu_processes(
self,
threshold: Optional[float] = None,
) -> List[Dict[str, Any]]:
"""Return processes whose CPU usage exceeds *threshold* percent.
Parameters
----------
threshold:
Override the instance-level ``cpu_threshold``.
"""
limit = threshold if threshold is not None else self.cpu_threshold
high: List[Dict[str, Any]] = []
for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]):
try:
info = proc.info
cpu = info.get("cpu_percent") or 0.0
if cpu > limit:
high.append({
"pid": info["pid"],
"name": info.get("name", ""),
"cmdline": info.get("cmdline") or [],
"cpu_percent": cpu,
"username": info.get("username", "?"),
"reason": f"High CPU usage: {cpu:.1f}%",
"severity": "HIGH",
})
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return high
# ------------------------------------------------------------------
# Hidden-process detection (Linux only)
# ------------------------------------------------------------------
@staticmethod
def find_hidden_processes() -> List[Dict[str, Any]]:
"""Detect processes visible in ``/proc`` but hidden from ``psutil``.
On non-Linux systems this returns an empty list.
A mismatch may indicate a userland rootkit that hooks the process
listing syscalls.
"""
proc_dir = Path("/proc")
if not proc_dir.is_dir():
return [] # not Linux
# PIDs visible via /proc filesystem.
proc_pids: set[int] = set()
try:
for entry in proc_dir.iterdir():
if entry.name.isdigit():
proc_pids.add(int(entry.name))
except PermissionError:
logger.warning("Cannot enumerate /proc")
return []
# PIDs visible via psutil (which ultimately calls getdents / readdir).
psutil_pids = set(psutil.pids())
hidden: List[Dict[str, Any]] = []
for pid in proc_pids - psutil_pids:
# Read whatever we can from /proc/<pid>.
name = ""
cmdline = ""
try:
comm = proc_dir / str(pid) / "comm"
if comm.exists():
name = comm.read_text().strip()
except OSError:
pass
try:
cl = proc_dir / str(pid) / "cmdline"
if cl.exists():
cmdline = cl.read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip()
except OSError:
pass
hidden.append({
"pid": pid,
"name": name,
"cmdline": cmdline,
"reason": "Process visible in /proc but hidden from psutil (possible rootkit)",
"severity": "CRITICAL",
})
return hidden
# ------------------------------------------------------------------
# Single-process detail
# ------------------------------------------------------------------
@staticmethod
def get_process_details(pid: int) -> Dict[str, Any]:
"""Return comprehensive information about a single process.
Raises
------
psutil.NoSuchProcess
If the PID does not exist.
psutil.AccessDenied
If the caller lacks permission to inspect the process.
"""
proc = psutil.Process(pid)
with proc.oneshot():
info: Dict[str, Any] = {
"pid": proc.pid,
"name": proc.name(),
"exe": "",
"cmdline": proc.cmdline(),
"status": proc.status(),
"username": "",
"cpu_percent": proc.cpu_percent(interval=0.1),
"memory_percent": proc.memory_percent(),
"memory_info": {},
"create_time": datetime.utcfromtimestamp(proc.create_time()).isoformat(),
"cwd": "",
"open_files": [],
"connections": [],
"threads": proc.num_threads(),
"nice": None,
"environ": {},
}
try:
info["exe"] = proc.exe()
except (psutil.AccessDenied, OSError):
pass
try:
info["username"] = proc.username()
except psutil.AccessDenied:
pass
try:
mem = proc.memory_info()
info["memory_info"] = {"rss": mem.rss, "vms": mem.vms}
except (psutil.AccessDenied, OSError):
pass
try:
info["cwd"] = proc.cwd()
except (psutil.AccessDenied, OSError):
pass
try:
info["open_files"] = [f.path for f in proc.open_files()]
except (psutil.AccessDenied, OSError):
pass
try:
info["connections"] = [
{
"laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "",
"raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "",
"status": c.status,
}
for c in proc.net_connections()
]
except (psutil.AccessDenied, OSError):
pass
try:
info["nice"] = proc.nice()
except (psutil.AccessDenied, OSError):
pass
try:
info["environ"] = dict(proc.environ())
except (psutil.AccessDenied, OSError):
pass
return info
# ------------------------------------------------------------------
# Process control
# ------------------------------------------------------------------
@staticmethod
def kill_process(pid: int) -> bool:
"""Send ``SIGKILL`` to the process with *pid*.
Returns ``True`` if the signal was delivered successfully, ``False``
otherwise (e.g. the process no longer exists or permission denied).
"""
try:
proc = psutil.Process(pid)
proc.kill() # SIGKILL
proc.wait(timeout=5)
logger.info("Killed process %d (%s)", pid, proc.name())
return True
except psutil.NoSuchProcess:
logger.warning("Process %d no longer exists", pid)
return False
except psutil.AccessDenied:
logger.error("Permission denied killing process %d", pid)
# Fall back to raw signal as a last resort.
try:
os.kill(pid, signal.SIGKILL)
logger.info("Killed process %d via os.kill", pid)
return True
except OSError as exc:
logger.error("os.kill(%d) failed: %s", pid, exc)
return False
except psutil.TimeoutExpired:
logger.warning("Process %d did not exit within timeout", pid)
return False
@@ -0,0 +1,251 @@
"""SQLite-backed malware hash database for AYN Antivirus.
Stores SHA-256 / MD5 hashes of known threats with associated metadata
(threat name, type, severity, source feed) and provides efficient lookup,
bulk-insert, search, and export operations.
"""
from __future__ import annotations
import csv
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from ayn_antivirus.constants import DEFAULT_DB_PATH
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
_SCHEMA = """
CREATE TABLE IF NOT EXISTS threats (
hash TEXT PRIMARY KEY,
threat_name TEXT NOT NULL,
threat_type TEXT NOT NULL DEFAULT 'MALWARE',
severity TEXT NOT NULL DEFAULT 'HIGH',
source TEXT NOT NULL DEFAULT '',
added_date TEXT NOT NULL DEFAULT (datetime('now')),
details TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_threats_type ON threats(threat_type);
CREATE INDEX IF NOT EXISTS idx_threats_source ON threats(source);
CREATE INDEX IF NOT EXISTS idx_threats_name ON threats(threat_name);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
class HashDatabase:
"""Manage a local SQLite database of known-malicious file hashes.
Parameters
----------
db_path:
Path to the SQLite file. Created automatically (with parent dirs)
if it doesn't exist.
"""
def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None:
self.db_path = Path(db_path)
self._conn: Optional[sqlite3.Connection] = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def initialize(self) -> None:
"""Open the database and create tables if necessary."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
self._conn.commit()
logger.info("HashDatabase opened: %s (%d hashes)", self.db_path, self.count())
def close(self) -> None:
"""Flush and close the database."""
if self._conn:
self._conn.close()
self._conn = None
@property
def conn(self) -> sqlite3.Connection:
if self._conn is None:
self.initialize()
assert self._conn is not None
return self._conn
# ------------------------------------------------------------------
# Single-record operations
# ------------------------------------------------------------------
def add_hash(
self,
hash_str: str,
threat_name: str,
threat_type: str = "MALWARE",
severity: str = "HIGH",
source: str = "",
details: str = "",
) -> None:
"""Insert or replace a single hash record."""
self.conn.execute(
"INSERT OR REPLACE INTO threats "
"(hash, threat_name, threat_type, severity, source, added_date, details) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
hash_str.lower(),
threat_name,
threat_type,
severity,
source,
datetime.utcnow().isoformat(),
details,
),
)
self.conn.commit()
def lookup(self, hash_str: str) -> Optional[Dict[str, Any]]:
"""Look up a hash and return its metadata, or ``None``."""
row = self.conn.execute(
"SELECT * FROM threats WHERE hash = ?", (hash_str.lower(),)
).fetchone()
if row is None:
return None
return dict(row)
def remove(self, hash_str: str) -> bool:
"""Delete a hash record. Returns ``True`` if a row was deleted."""
cur = self.conn.execute(
"DELETE FROM threats WHERE hash = ?", (hash_str.lower(),)
)
self.conn.commit()
return cur.rowcount > 0
# ------------------------------------------------------------------
# Bulk operations
# ------------------------------------------------------------------
def bulk_add(
self,
records: Sequence[Tuple[str, str, str, str, str, str]],
) -> int:
"""Efficiently insert new hashes in a single transaction.
Uses ``INSERT OR IGNORE`` so existing entries are preserved and
only genuinely new hashes are counted.
Parameters
----------
records:
Sequence of ``(hash, threat_name, threat_type, severity, source, details)``
tuples.
Returns
-------
int
Number of **new** rows actually inserted.
"""
if not records:
return 0
now = datetime.utcnow().isoformat()
rows = [
(h.lower(), name, ttype, sev, src, now, det)
for h, name, ttype, sev, src, det in records
]
before = self.count()
self.conn.executemany(
"INSERT OR IGNORE INTO threats "
"(hash, threat_name, threat_type, severity, source, added_date, details) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
rows,
)
self.conn.commit()
return self.count() - before
# ------------------------------------------------------------------
# Query helpers
# ------------------------------------------------------------------
def count(self) -> int:
"""Total number of hashes in the database."""
return self.conn.execute("SELECT COUNT(*) FROM threats").fetchone()[0]
def get_stats(self) -> Dict[str, Any]:
"""Return aggregate statistics about the database."""
c = self.conn
by_type = {
row[0]: row[1]
for row in c.execute(
"SELECT threat_type, COUNT(*) FROM threats GROUP BY threat_type"
).fetchall()
}
by_source = {
row[0]: row[1]
for row in c.execute(
"SELECT source, COUNT(*) FROM threats GROUP BY source"
).fetchall()
}
latest = c.execute(
"SELECT MAX(added_date) FROM threats"
).fetchone()[0]
return {
"total": self.count(),
"by_type": by_type,
"by_source": by_source,
"latest_update": latest,
}
def search(self, query: str) -> List[Dict[str, Any]]:
"""Search threat names with a SQL LIKE pattern.
Example: ``search("%Trojan%")``
"""
rows = self.conn.execute(
"SELECT * FROM threats WHERE threat_name LIKE ? ORDER BY added_date DESC LIMIT 500",
(query,),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# Export
# ------------------------------------------------------------------
def export_hashes(self, filepath: str | Path) -> int:
"""Export all hashes to a CSV file. Returns the row count."""
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
rows = self.conn.execute(
"SELECT hash, threat_name, threat_type, severity, source, added_date, details "
"FROM threats ORDER BY added_date DESC"
).fetchall()
with open(filepath, "w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["hash", "threat_name", "threat_type", "severity", "source", "added_date", "details"])
for row in rows:
writer.writerow(list(row))
return len(rows)
# ------------------------------------------------------------------
# Meta helpers (used by manager to track feed state)
# ------------------------------------------------------------------
def set_meta(self, key: str, value: str) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value)
)
self.conn.commit()
def get_meta(self, key: str) -> Optional[str]:
row = self.conn.execute(
"SELECT value FROM meta WHERE key = ?", (key,)
).fetchone()
return row[0] if row else None
@@ -0,0 +1,259 @@
"""SQLite-backed Indicator of Compromise (IOC) database for AYN Antivirus.
Stores malicious IPs, domains, and URLs sourced from threat-intelligence
feeds so that the network scanner and detectors can perform real-time
lookups.
"""
from __future__ import annotations
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from ayn_antivirus.constants import DEFAULT_DB_PATH
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
_SCHEMA = """
CREATE TABLE IF NOT EXISTS ioc_ips (
ip TEXT PRIMARY KEY,
threat_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'C2',
source TEXT NOT NULL DEFAULT '',
added_date TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ioc_ips_source ON ioc_ips(source);
CREATE TABLE IF NOT EXISTS ioc_domains (
domain TEXT PRIMARY KEY,
threat_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'C2',
source TEXT NOT NULL DEFAULT '',
added_date TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ioc_domains_source ON ioc_domains(source);
CREATE TABLE IF NOT EXISTS ioc_urls (
url TEXT PRIMARY KEY,
threat_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'malware_distribution',
source TEXT NOT NULL DEFAULT '',
added_date TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ioc_urls_source ON ioc_urls(source);
"""
class IOCDatabase:
"""Manage a local SQLite store of Indicators of Compromise.
Parameters
----------
db_path:
Path to the SQLite file. Shares the same file as
:class:`HashDatabase` by default; each uses its own tables.
"""
_VALID_TABLES: frozenset = frozenset({"ioc_ips", "ioc_domains", "ioc_urls"})
def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None:
self.db_path = Path(db_path)
self._conn: Optional[sqlite3.Connection] = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def initialize(self) -> None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
self._conn.commit()
logger.info(
"IOCDatabase opened: %s (IPs=%d, domains=%d, URLs=%d)",
self.db_path,
self._count("ioc_ips"),
self._count("ioc_domains"),
self._count("ioc_urls"),
)
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
@property
def conn(self) -> sqlite3.Connection:
if self._conn is None:
self.initialize()
assert self._conn is not None
return self._conn
def _count(self, table: str) -> int:
if table not in self._VALID_TABLES:
raise ValueError(f"Invalid table name: {table}")
return self.conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
# ------------------------------------------------------------------
# IPs
# ------------------------------------------------------------------
def add_ip(
self,
ip: str,
threat_name: str = "",
type: str = "C2",
source: str = "",
) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO ioc_ips (ip, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
(ip, threat_name, type, source, datetime.utcnow().isoformat()),
)
self.conn.commit()
def bulk_add_ips(
self,
records: Sequence[Tuple[str, str, str, str]],
) -> int:
"""Bulk-insert IPs. Each tuple: ``(ip, threat_name, type, source)``.
Returns the number of **new** rows actually inserted.
"""
if not records:
return 0
now = datetime.utcnow().isoformat()
rows = [(ip, tn, t, src, now) for ip, tn, t, src in records]
before = self._count("ioc_ips")
self.conn.executemany(
"INSERT OR IGNORE INTO ioc_ips (ip, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
rows,
)
self.conn.commit()
return self._count("ioc_ips") - before
def lookup_ip(self, ip: str) -> Optional[Dict[str, Any]]:
row = self.conn.execute(
"SELECT * FROM ioc_ips WHERE ip = ?", (ip,)
).fetchone()
return dict(row) if row else None
def get_all_malicious_ips(self) -> Set[str]:
"""Return every stored malicious IP as a set for fast membership tests."""
rows = self.conn.execute("SELECT ip FROM ioc_ips").fetchall()
return {row[0] for row in rows}
# ------------------------------------------------------------------
# Domains
# ------------------------------------------------------------------
def add_domain(
self,
domain: str,
threat_name: str = "",
type: str = "C2",
source: str = "",
) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO ioc_domains (domain, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
(domain.lower(), threat_name, type, source, datetime.utcnow().isoformat()),
)
self.conn.commit()
def bulk_add_domains(
self,
records: Sequence[Tuple[str, str, str, str]],
) -> int:
"""Bulk-insert domains. Each tuple: ``(domain, threat_name, type, source)``.
Returns the number of **new** rows actually inserted.
"""
if not records:
return 0
now = datetime.utcnow().isoformat()
rows = [(d.lower(), tn, t, src, now) for d, tn, t, src in records]
before = self._count("ioc_domains")
self.conn.executemany(
"INSERT OR IGNORE INTO ioc_domains (domain, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
rows,
)
self.conn.commit()
return self._count("ioc_domains") - before
def lookup_domain(self, domain: str) -> Optional[Dict[str, Any]]:
row = self.conn.execute(
"SELECT * FROM ioc_domains WHERE domain = ?", (domain.lower(),)
).fetchone()
return dict(row) if row else None
def get_all_malicious_domains(self) -> Set[str]:
"""Return every stored malicious domain as a set."""
rows = self.conn.execute("SELECT domain FROM ioc_domains").fetchall()
return {row[0] for row in rows}
# ------------------------------------------------------------------
# URLs
# ------------------------------------------------------------------
def add_url(
self,
url: str,
threat_name: str = "",
type: str = "malware_distribution",
source: str = "",
) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO ioc_urls (url, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
(url, threat_name, type, source, datetime.utcnow().isoformat()),
)
self.conn.commit()
def bulk_add_urls(
self,
records: Sequence[Tuple[str, str, str, str]],
) -> int:
"""Bulk-insert URLs. Each tuple: ``(url, threat_name, type, source)``.
Returns the number of **new** rows actually inserted.
"""
if not records:
return 0
now = datetime.utcnow().isoformat()
rows = [(u, tn, t, src, now) for u, tn, t, src in records]
before = self._count("ioc_urls")
self.conn.executemany(
"INSERT OR IGNORE INTO ioc_urls (url, threat_name, type, source, added_date) "
"VALUES (?, ?, ?, ?, ?)",
rows,
)
self.conn.commit()
return self._count("ioc_urls") - before
def lookup_url(self, url: str) -> Optional[Dict[str, Any]]:
row = self.conn.execute(
"SELECT * FROM ioc_urls WHERE url = ?", (url,)
).fetchone()
return dict(row) if row else None
# ------------------------------------------------------------------
# Aggregate stats
# ------------------------------------------------------------------
def get_stats(self) -> Dict[str, Any]:
return {
"ips": self._count("ioc_ips"),
"domains": self._count("ioc_domains"),
"urls": self._count("ioc_urls"),
}
@@ -0,0 +1,92 @@
"""Abstract base class for AYN threat-intelligence feeds."""
from __future__ import annotations
import logging
import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class BaseFeed(ABC):
"""Common interface for all external threat-intelligence feeds.
Provides rate-limiting, last-updated tracking, and a uniform
``fetch()`` contract so the :class:`SignatureManager` can orchestrate
updates without knowing feed internals.
Parameters
----------
rate_limit_seconds:
Minimum interval between successive HTTP requests to the same feed.
"""
def __init__(self, rate_limit_seconds: float = 2.0) -> None:
self._rate_limit = rate_limit_seconds
self._last_request_time: float = 0.0
self._last_updated: Optional[datetime] = None
# ------------------------------------------------------------------
# Identity
# ------------------------------------------------------------------
@abstractmethod
def get_name(self) -> str:
"""Return a short, human-readable feed name."""
...
# ------------------------------------------------------------------
# Fetching
# ------------------------------------------------------------------
@abstractmethod
def fetch(self) -> List[Dict[str, Any]]:
"""Download the latest entries from the feed.
Returns a list of dicts. The exact keys depend on the feed type
(hashes, IOCs, rules, etc.). The :class:`SignatureManager` is
responsible for routing each entry to the correct database.
"""
...
# ------------------------------------------------------------------
# State
# ------------------------------------------------------------------
@property
def last_updated(self) -> Optional[datetime]:
"""Timestamp of the most recent successful fetch."""
return self._last_updated
def _mark_updated(self) -> None:
"""Record the current time as the last-successful-fetch timestamp."""
self._last_updated = datetime.utcnow()
# ------------------------------------------------------------------
# Rate limiting
# ------------------------------------------------------------------
def _rate_limit_wait(self) -> None:
"""Block until the rate-limit window has elapsed."""
elapsed = time.monotonic() - self._last_request_time
remaining = self._rate_limit - elapsed
if remaining > 0:
logger.debug("[%s] Rate-limiting: sleeping %.1fs", self.get_name(), remaining)
time.sleep(remaining)
self._last_request_time = time.monotonic()
# ------------------------------------------------------------------
# Logging helpers
# ------------------------------------------------------------------
def _log(self, msg: str, *args: Any) -> None:
logger.info("[%s] " + msg, self.get_name(), *args)
def _warn(self, msg: str, *args: Any) -> None:
logger.warning("[%s] " + msg, self.get_name(), *args)
def _error(self, msg: str, *args: Any) -> None:
logger.error("[%s] " + msg, self.get_name(), *args)
@@ -0,0 +1,124 @@
"""Emerging Threats (ET Open) feed for AYN Antivirus.
Parses community Suricata / Snort rules from Proofpoint's ET Open project
to extract IOCs (IP addresses and domains) referenced in active detection
rules.
Source: https://rules.emergingthreats.net/open/suricata/rules/
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Set
import requests
from ayn_antivirus.signatures.feeds.base_feed import BaseFeed
logger = logging.getLogger(__name__)
# We focus on the compromised-IP and C2 rule files.
_RULE_URLS = [
"https://rules.emergingthreats.net/open/suricata/rules/compromised-ips.txt",
"https://rules.emergingthreats.net/open/suricata/rules/botcc.rules",
"https://rules.emergingthreats.net/open/suricata/rules/ciarmy.rules",
"https://rules.emergingthreats.net/open/suricata/rules/emerging-malware.rules",
]
_TIMEOUT = 30
# Regex patterns to extract IPs and domains from rule bodies.
_RE_IPV4 = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
_RE_DOMAIN = re.compile(
r'content:"([a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?'
r'(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*'
r'\.[a-zA-Z]{2,})"'
)
# Private / non-routable ranges to exclude from IP results.
_PRIVATE_PREFIXES = (
"10.", "127.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
"192.168.", "0.", "255.", "224.",
)
class EmergingThreatsFeed(BaseFeed):
"""Parse ET Open rule files to extract malicious IPs and domains."""
def get_name(self) -> str:
return "emergingthreats"
def fetch(self) -> List[Dict[str, Any]]:
"""Download and parse ET Open rules, returning IOC dicts.
Each dict has: ``ioc_type`` (``"ip"`` or ``"domain"``), ``value``,
``threat_name``, ``type``, ``source``.
"""
self._log("Downloading ET Open rule files")
all_ips: Set[str] = set()
all_domains: Set[str] = set()
for url in _RULE_URLS:
self._rate_limit_wait()
try:
resp = requests.get(url, timeout=_TIMEOUT)
resp.raise_for_status()
text = resp.text
except requests.RequestException as exc:
self._warn("Failed to fetch %s: %s", url, exc)
continue
# Extract IPs.
if url.endswith(".txt"):
# Plain text IP list (one per line).
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = _RE_IPV4.match(line)
if match:
ip = match.group(1)
if not ip.startswith(_PRIVATE_PREFIXES):
all_ips.add(ip)
else:
# Suricata rule file — extract IPs from rule body.
for ip_match in _RE_IPV4.finditer(text):
ip = ip_match.group(1)
if not ip.startswith(_PRIVATE_PREFIXES):
all_ips.add(ip)
# Extract domains from content matches.
for domain_match in _RE_DOMAIN.finditer(text):
domain = domain_match.group(1).lower()
# Filter out very short or generic patterns.
if "." in domain and len(domain) > 4:
all_domains.add(domain)
# Build result list.
results: List[Dict[str, Any]] = []
for ip in all_ips:
results.append({
"ioc_type": "ip",
"value": ip,
"threat_name": "ET.Compromised",
"type": "C2",
"source": "emergingthreats",
"details": "IP from Emerging Threats ET Open rules",
})
for domain in all_domains:
results.append({
"ioc_type": "domain",
"value": domain,
"threat_name": "ET.MaliciousDomain",
"type": "C2",
"source": "emergingthreats",
"details": "Domain extracted from ET Open Suricata rules",
})
self._log("Extracted %d IP(s) and %d domain(s)", len(all_ips), len(all_domains))
self._mark_updated()
return results
@@ -0,0 +1,73 @@
"""Feodo Tracker feed for AYN Antivirus.
Downloads the recommended IP blocklist from the abuse.ch Feodo Tracker
project. The list contains IP addresses of verified botnet C2 servers
(Dridex, Emotet, TrickBot, QakBot, etc.).
Source: https://feodotracker.abuse.ch/blocklist/
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
import requests
from ayn_antivirus.signatures.feeds.base_feed import BaseFeed
logger = logging.getLogger(__name__)
_BLOCKLIST_URL = "https://feodotracker.abuse.ch/downloads/ipblocklist_aggressive.txt"
_TIMEOUT = 30
class FeodoTrackerFeed(BaseFeed):
"""Fetch C2 server IPs from the Feodo Tracker blocklist."""
def get_name(self) -> str:
return "feodotracker"
def fetch(self) -> List[Dict[str, Any]]:
"""Download the recommended IP blocklist.
Returns a list of dicts, each with:
``ioc_type="ip"``, ``value``, ``threat_name``, ``type``, ``source``.
"""
self._rate_limit_wait()
self._log("Downloading Feodo Tracker IP blocklist")
try:
resp = requests.get(_BLOCKLIST_URL, timeout=_TIMEOUT)
resp.raise_for_status()
except requests.RequestException as exc:
self._error("Download failed: %s", exc)
return []
results: List[Dict[str, Any]] = []
for line in resp.text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Basic IPv4 validation.
parts = line.split(".")
if len(parts) != 4:
continue
try:
if not all(0 <= int(p) <= 255 for p in parts):
continue
except ValueError:
continue
results.append({
"ioc_type": "ip",
"value": line,
"threat_name": "Botnet.C2.Feodo",
"type": "C2",
"source": "feodotracker",
"details": "Verified botnet C2 IP from Feodo Tracker",
})
self._log("Fetched %d C2 IP(s)", len(results))
self._mark_updated()
return results
@@ -0,0 +1,174 @@
"""MalwareBazaar feed for AYN Antivirus.
Fetches recent malware sample hashes from the abuse.ch MalwareBazaar
CSV export (free, no API key required).
CSV export: https://bazaar.abuse.ch/export/
"""
from __future__ import annotations
import csv
import io
import logging
from typing import Any, Dict, List, Optional
import requests
from ayn_antivirus.signatures.feeds.base_feed import BaseFeed
logger = logging.getLogger(__name__)
_CSV_RECENT_URL = "https://bazaar.abuse.ch/export/csv/recent/"
_CSV_FULL_URL = "https://bazaar.abuse.ch/export/csv/full/"
_API_URL = "https://mb-api.abuse.ch/api/v1/"
_TIMEOUT = 60
class MalwareBazaarFeed(BaseFeed):
"""Fetch malware SHA-256 hashes from MalwareBazaar.
Uses the free CSV export by default. Falls back to JSON API
if an api_key is provided.
"""
def __init__(self, api_key: Optional[str] = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.api_key = api_key
def get_name(self) -> str:
return "malwarebazaar"
def fetch(self) -> List[Dict[str, Any]]:
"""Fetch recent malware hashes from CSV export."""
return self._fetch_csv(_CSV_RECENT_URL)
def fetch_recent(self, hours: int = 24) -> List[Dict[str, Any]]:
"""Fetch recent samples. CSV export returns last ~1000 samples."""
return self._fetch_csv(_CSV_RECENT_URL)
def _fetch_csv(self, url: str) -> List[Dict[str, Any]]:
"""Download and parse the MalwareBazaar CSV export."""
self._rate_limit_wait()
self._log("Fetching hashes from %s", url)
try:
resp = requests.get(url, timeout=_TIMEOUT)
resp.raise_for_status()
except requests.RequestException as exc:
self._error("CSV download failed: %s", exc)
return []
results: List[Dict[str, Any]] = []
lines = [
line for line in resp.text.splitlines()
if line.strip() and not line.startswith("#")
]
reader = csv.reader(io.StringIO("\n".join(lines)))
for row in reader:
if len(row) < 8:
continue
# CSV columns:
# 0: first_seen, 1: sha256, 2: md5, 3: sha1,
# 4: reporter, 5: filename, 6: file_type, 7: mime_type,
# 8+: signature, ...
sha256 = row[1].strip().strip('"')
if not sha256 or len(sha256) != 64:
continue
filename = row[5].strip().strip('"') if len(row) > 5 else ""
file_type = row[6].strip().strip('"') if len(row) > 6 else ""
signature = row[8].strip().strip('"') if len(row) > 8 else ""
reporter = row[4].strip().strip('"') if len(row) > 4 else ""
threat_name = (
signature
if signature and signature not in ("null", "n/a", "None", "")
else f"Malware.{_map_type_name(file_type)}"
)
results.append({
"hash": sha256.lower(),
"threat_name": threat_name,
"threat_type": _map_type(file_type),
"severity": "HIGH",
"source": "malwarebazaar",
"details": (
f"file={filename}, type={file_type}, reporter={reporter}"
),
})
self._log("Parsed %d hash signature(s) from CSV", len(results))
self._mark_updated()
return results
def fetch_by_tag(self, tag: str) -> List[Dict[str, Any]]:
"""Fetch samples by tag (requires API key, falls back to empty)."""
if not self.api_key:
self._warn("fetch_by_tag requires API key")
return []
self._rate_limit_wait()
payload = {"query": "get_taginfo", "tag": tag, "limit": 100}
if self.api_key:
payload["api_key"] = self.api_key
try:
resp = requests.post(_API_URL, data=payload, timeout=_TIMEOUT)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as exc:
self._error("API request failed: %s", exc)
return []
if data.get("query_status") != "ok":
return []
results = []
for entry in data.get("data", []):
sha256 = entry.get("sha256_hash", "")
if not sha256:
continue
results.append({
"hash": sha256.lower(),
"threat_name": entry.get("signature") or f"Malware.{tag}",
"threat_type": _map_type(entry.get("file_type", "")),
"severity": "HIGH",
"source": "malwarebazaar",
"details": f"tag={tag}, file_type={entry.get('file_type', '')}",
})
self._mark_updated()
return results
def _map_type(file_type: str) -> str:
ft = file_type.lower()
if any(x in ft for x in ("exe", "dll", "elf", "pe32")):
return "MALWARE"
if any(x in ft for x in ("doc", "xls", "pdf", "rtf")):
return "MALWARE"
if any(x in ft for x in ("script", "js", "vbs", "ps1", "bat", "sh")):
return "MALWARE"
return "MALWARE"
def _map_type_name(file_type: str) -> str:
"""Map file type to a readable threat name suffix."""
ft = file_type.lower().strip()
m = {
"exe": "Win32.Executable", "dll": "Win32.DLL", "msi": "Win32.Installer",
"elf": "Linux.ELF", "so": "Linux.SharedLib",
"doc": "Office.Document", "docx": "Office.Document",
"xls": "Office.Spreadsheet", "xlsx": "Office.Spreadsheet",
"pdf": "PDF.Document", "rtf": "Office.RTF",
"js": "Script.JavaScript", "vbs": "Script.VBScript",
"ps1": "Script.PowerShell", "bat": "Script.Batch",
"sh": "Script.Shell", "py": "Script.Python",
"apk": "Android.APK", "ipa": "iOS.IPA",
"app": "macOS.App", "pkg": "macOS.Pkg", "dmg": "macOS.DMG",
"rar": "Archive.RAR", "zip": "Archive.ZIP",
"7z": "Archive.7Z", "tar": "Archive.TAR", "gz": "Archive.GZ",
"iso": "DiskImage.ISO", "img": "DiskImage.IMG",
}
return m.get(ft, "Generic")

Some files were not shown because too many files have changed in this diff Show More