Skip to content
Open
Show file tree
Hide file tree
Changes from all 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