Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/assistant-attachment-malware-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@aragon/assistant": minor
"@aragon/assistant-contracts": minor
---

Scan chat attachments for malware before they are queued: `/files/confirm` now calls the file-malware-scanner worker (VirusTotal + Claude) after the format gate, deletes the blob of a flagged file and rejects it with `malicious_file`. Per-engine policy (`off` / `optional` / `mandatory`) and a kill switch live in the assistant config, with `ASSISTANT_MALWARE_SCAN_*` env overrides.
5 changes: 5 additions & 0 deletions .changeset/assistant-chat-malware-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/assistant-chat": minor
---

Show a dedicated alert on an attachment the malware scan rejected ("Malicious code detected. The file was not attached.") and a retry hint when the scan is unavailable.
8 changes: 8 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ f5f578dfd4020c4a38f54eb5980a2310eefe94c3:.yarn/releases/yarn-4.3.0.cjs:generic-a
59da625af2ff0be48f582d288d73cbf4c52bf105:.yarn/releases/yarn-4.1.1.cjs:generic-api-key:567
73f7080432457e358b3678fb02d4b5c466e763eb:.yarn/releases/yarn-4.9.1.cjs:generic-api-key:589
140074ec8d53b2cac8c8bf84f7eecd5522f53201:.yarn/releases/yarn-4.4.0.cjs:generic-api-key:567

# Public ERC-20 contract address used as a test fixture in a variable named `token`
# (blockchain address, not a credential) — gitleaks reads it as a generic API key. Same
# finding as the permissionsList entry above, re-fingerprinted after the files moved to
# apps/app in the monorepo migration.
8f5ede4daeaaaa56ec7747fbf14a3c527b4c3194:apps/app/src/modules/settings/components/permissionCondition/permissionCondition.test.tsx:generic-api-key:41
8f5ede4daeaaaa56ec7747fbf14a3c527b4c3194:apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx:generic-api-key:46
8f5ede4daeaaaa56ec7747fbf14a3c527b4c3194:apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx:generic-api-key:381
13 changes: 13 additions & 0 deletions apps/assistant/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ PORT=4000
# ASSISTANT_RATE_LIMIT_RPM=10
# ASSISTANT_RATE_LIMIT_SESSIONS_PER_DAY=20

# Malware scanning of attachments (defaults live in src/lib/config.ts). The kill switch and the
# per-engine policy: off (ignore the engine) | optional (only a detection blocks) | mandatory
# (a missing verdict blocks too, retriably).
# ASSISTANT_MALWARE_SCAN_ENABLED=true
# ASSISTANT_MALWARE_SCANNER_URL=https://file-malware-scanner-0.aragon-project.workers.dev
# ASSISTANT_MALWARE_SCAN_CLAUDE=mandatory
# ASSISTANT_MALWARE_SCAN_VIRUSTOTAL=optional

# --- Secrets (1Password: kv_assistant_<env>) ---

# Vercel AI Gateway key (dedicated key with a spend budget)
Expand All @@ -46,3 +54,8 @@ CRON_SECRET=

# Sentry DSN of the assistant project
SENTRY_DSN=

# Cloudflare Access service token of the file-malware-scanner worker (both halves required;
# without them the scan call is unauthenticated and Access rejects it)
MALWARE_SCANNER_ACCESS_CLIENT_ID=
MALWARE_SCANNER_ACCESS_CLIENT_SECRET=
6 changes: 4 additions & 2 deletions apps/assistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ All chat/assistant copy is centralized in two places: service-side texts live in

Users can attach files (images, text/log, PDF) to a support request. Bytes go **client → Vercel Blob directly**, are validated server-side by magic bytes + size (`src/files/validateFile.ts`), queued per session, and move to the **private** Linear ticket only when the ticket is created; abandoned blobs are swept by the daily `/internal/cleanup` cron.

**Current moderation posture (p4): deterrence + reactive.** There is intentionally **no** automated NSFW/illegal-content scanning. The risk is bounded by: a private end-to-end path (Blob → private Linear queue, never public), the type/size allowlist, per-IP rate + session limits (`src/lib/rateLimit.ts`, `src/lib/config.ts`), a small per-session file cap, a client-side upload disclaimer, and quick deletion. Uploaded content is reviewed reactively by the support team.
**Malware scanning.** Every confirmed upload is scanned before it is queued: `/files/confirm` downloads the blob, runs the magic-byte gate, then calls the `file-malware-scanner` Cloudflare worker (VirusTotal + Claude, see the `cloudflare-management` repo). A flagged file is **deleted from the blob store and never queued** — it reaches neither the ticket nor the support team — and the widget shows "Malicious code detected". Each engine has its own policy in `src/lib/config.ts` (`malwareScan`): `off` ignores it, `optional` blocks only on a positive detection, `mandatory` also blocks when the engine returns no verdict (retriable `scan_unavailable`). Defaults: Claude `mandatory`, VirusTotal `optional` (its free tier rate-limits at 4 req/min and knows no hash for freshly created files). The whole feature is behind `malwareScan.enabled` — off in production until validated on dev/preview — and every setting has an `ASSISTANT_MALWARE_SCAN_*` env override so a single deployment can flip it without a code change.

**Content moderation posture (p4): deterrence + reactive.** Beyond malware, there is intentionally **no** automated NSFW/illegal-content scanning. The risk is bounded by: a private end-to-end path (Blob → private Linear queue, never public), the type/size allowlist, per-IP rate + session limits (`src/lib/rateLimit.ts`, `src/lib/config.ts`), a small per-session file cap, a client-side upload disclaimer, and quick deletion. Uploaded content is reviewed reactively by the support team.

**Deferred (not in p4), tracked as follow-ups:**

- **Automated vision moderation** — a safety classification of accepted images at `/files/confirm` via the existing AI Gateway, before the file is queued. The hook point is marked with a `TODO(assistant)` in `src/files/validateFile.ts`.
- **Automated NSFW/vision moderation** — a safety classification of accepted images, orthogonal to the malware scan above (which targets malicious payloads, not explicit content).
- **CSAM / illegal content** — needs a dedicated provider (hash-matching / reporting obligations); a policy + provider decision outside this codebase, not a model call.
- **Console-log ring buffer** — an app-side `console.*` interceptor (last N lines, privacy-scrubbed) attached to the ticket as a `.log`. The next debug-signal increment after the cheap context already attached (chainId, recent transactions, Sentry `user.id` replay pointer).

Expand Down
188 changes: 188 additions & 0 deletions apps/assistant/src/files/malwareScanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { createMalwareScanner } from './malwareScanner';

const buildScanResponse = (params: {
allowed: boolean;
claudeStatus: string;
virusTotalStatus: string;
reason?: string | null;
}) =>
new Response(
JSON.stringify({
apiVersion: 'v1',
allowed: params.allowed,
reason: params.reason ?? null,
verdicts: {
claude: {
status: params.claudeStatus,
reason: 'Analyzer reason.',
},
virustotal: { status: params.virusTotalStatus },
},
}),
{ status: params.allowed ? 200 : 422 },
);

const mockFetch = (response: Response | Error) => {
const fetchMock = jest.fn(() =>
response instanceof Error
? Promise.reject(response)
: Promise.resolve(response),
);
global.fetch = fetchMock as unknown as typeof fetch;

return fetchMock;
};

const scanPng = () =>
createMalwareScanner().scan({
data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
filename: 'screenshot.png',
});

// The per-engine policy (off / optional / mandatory) is the whole point of this seam: it decides
// whether an engine's silence costs the user their attachment.
describe('malwareScanner', () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
process.env.ASSISTANT_MALWARE_SCAN_CLAUDE = undefined;
process.env.ASSISTANT_MALWARE_SCAN_VIRUSTOTAL = undefined;
});

it('passes a file both engines consider clean', async () => {
mockFetch(
buildScanResponse({
allowed: true,
claudeStatus: 'clean',
virusTotalStatus: 'clean',
}),
);

await expect(scanPng()).resolves.toEqual({ status: 'clean' });
});

it('flags a file the AI analyzer marked malicious', async () => {
mockFetch(
buildScanResponse({
allowed: false,
claudeStatus: 'malicious',
virusTotalStatus: 'unknown',
}),
);

await expect(scanPng()).resolves.toEqual({
status: 'malicious',
reason: 'Analyzer reason.',
});
});

it('treats an analyzer refusal as a detection', async () => {
mockFetch(
buildScanResponse({
allowed: false,
claudeStatus: 'refused',
virusTotalStatus: 'unknown',
}),
);

await expect(scanPng()).resolves.toMatchObject({
status: 'malicious',
});
});

it('passes when an optional engine has no verdict', async () => {
// Default policy: VirusTotal is optional, so a hash it has never seen must not block.
mockFetch(
buildScanResponse({
allowed: true,
claudeStatus: 'clean',
virusTotalStatus: 'unknown',
}),
);

await expect(scanPng()).resolves.toEqual({ status: 'clean' });
});

it('reports unavailable when a mandatory engine has no verdict', async () => {
mockFetch(
buildScanResponse({
allowed: true,
claudeStatus: 'error',
virusTotalStatus: 'clean',
}),
);

await expect(scanPng()).resolves.toMatchObject({
status: 'unavailable',
});
});

it('ignores an engine turned off, even on a detection', async () => {
process.env.ASSISTANT_MALWARE_SCAN_VIRUSTOTAL = 'off';
mockFetch(
buildScanResponse({
allowed: false,
claudeStatus: 'clean',
virusTotalStatus: 'malicious',
}),
);

await expect(scanPng()).resolves.toEqual({ status: 'clean' });
});

it('blocks on a mandatory VirusTotal detection when configured that way', async () => {
process.env.ASSISTANT_MALWARE_SCAN_VIRUSTOTAL = 'mandatory';
mockFetch(
buildScanResponse({
allowed: false,
claudeStatus: 'clean',
virusTotalStatus: 'malicious',
reason: 'Flagged by 12 engines.',
}),
);

await expect(scanPng()).resolves.toEqual({
status: 'malicious',
reason: 'Flagged by 12 engines.',
});
});

it('prefers a detection over an unavailable engine', async () => {
mockFetch(
buildScanResponse({
allowed: false,
claudeStatus: 'error',
virusTotalStatus: 'malicious',
}),
);
process.env.ASSISTANT_MALWARE_SCAN_VIRUSTOTAL = 'mandatory';

await expect(scanPng()).resolves.toMatchObject({
status: 'malicious',
});
});

it('reports unavailable when the scanner itself is unreachable', async () => {
mockFetch(new Error('network down'));

await expect(scanPng()).resolves.toMatchObject({
status: 'unavailable',
});
});

it('passes when the scanner is unreachable but no engine is mandatory', async () => {
process.env.ASSISTANT_MALWARE_SCAN_CLAUDE = 'optional';
mockFetch(new Error('network down'));

await expect(scanPng()).resolves.toEqual({ status: 'clean' });
});

it('reports unavailable on an unexpected scanner status code', async () => {
mockFetch(new Response('nope', { status: 500 }));

await expect(scanPng()).resolves.toMatchObject({
status: 'unavailable',
});
});
});
Loading
Loading