Files
AfiS-Aktensystem/web/lib/files/clamav.ts
T
Harlekin 4652dd08ec
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Virenpruefung an clamd anbinden, wo clamd tatsaechlich lauscht
Die Adresse zeigte auf /var/run/clamav/clamd.sock in einem geteilten
Volume. Das offizielle clamav-Image legt seinen Unix-Socket aber unter
/tmp im eigenen Container an - nachgemessen, das Verzeichnis im Volume
bleibt leer. Jeder Verbindungsversuch lief damit in ENOENT, und die
Statusseite meldete die Virenpruefung dauerhaft als gestoert.

Folgenreich war das nicht nur fuer die Anzeige: ohne erreichbaren Scanner
bleiben Uploads auf "Pruefung unvollstaendig" liegen und brauchen die
Freigabe von Hand. Die Kette hat also gehalten, aber niemand kam durch.

Dasselbe Image oeffnet ab Werk TCPSocket 3310. Darueber laeuft die
Anbindung jetzt; der Port ist im Stack nicht veroeffentlicht und nur im
internen Netz erreichbar. CLAMAV_ADDRESS nimmt weiterhin einen Pfad an,
falls irgendwo doch ein Unix-Socket erreichbar ist.

Das Socket-Volume faellt weg, es enthielt nie etwas. Der Scanner sieht
ausserdem die Dateiablage nicht mehr: uebertragen wird per INSTREAM durch
die Verbindung, gelesen hat er dort nie etwas.

Geprueft gegen ein laufendes clamd, einschliesslich EICAR. Diese Pruefung
bleibt als abschaltbarer Teil der Suite stehen:

  CLAMAV_LIVE=clamav:3310 pnpm --filter @afis/web test pruefkette

Die uebrigen neuen Pruefungen decken das Zerlegen der Adresse ab. Sie
haetten den Fehler nicht gefunden - eine Adresse, die auf nichts zeigt,
ist syntaktisch tadellos. Nur eine echte Verbindung beantwortet das.
2026-09-04 15:04:51 +02:00

127 lines
4.3 KiB
TypeScript

import net from 'node:net'
/**
* Anbindung an clamd.
*
* Es wird **INSTREAM** benutzt, nicht SCAN mit einem Pfad: dann muss der
* Scanner die Datei nicht sehen können, und die Anbindung funktioniert auch,
* wenn clamd anderswo läuft. Das Protokoll ist simpel - Länge als 4 Byte,
* Nutzdaten, dann eine Null-Länge als Abschluss.
*
* Die Adresse ist entweder `host:port` oder ein Pfad zu einem Unix-Socket.
* Vorgabe ist TCP, weil das offizielle clamav-Image seinen Unix-Socket unter
* `/tmp/clamd.sock` anlegt und nicht dort, wo ein geteiltes Volume ihn
* sichtbar machen könnte - ein Socket-Volume blieb schlicht leer und die
* Virenprüfung meldete sich dauerhaft als gestört. `TCPSocket 3310` ist in
* demselben Image ab Werk offen, und im Stack ist der Port nicht nach aussen
* veröffentlicht.
*/
/** `host:port` oder ein absoluter Pfad. Getrennt, damit es prüfbar ist. */
export type ClamavAddress = { path: string } | { host: string; port: number }
export function parseClamavAddress(address: string): ClamavAddress {
const sauber = address.trim()
if (sauber.startsWith('/') || sauber.startsWith('.')) return { path: sauber }
// Von hinten trennen, damit eine IPv6-Adresse in Klammern heil bleibt.
const trenner = sauber.lastIndexOf(':')
if (trenner <= 0) throw new Error(`clamd-Adresse ohne Port: ${address}`)
const port = Number(sauber.slice(trenner + 1))
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(`clamd-Adresse mit ungültigem Port: ${address}`)
}
const host = sauber.slice(0, trenner).replace(/^\[|\]$/g, '')
if (host.length === 0) throw new Error(`clamd-Adresse ohne Host: ${address}`)
return { host, port }
}
function verbinden(address: string): net.Socket {
const ziel = parseClamavAddress(address)
return 'path' in ziel
? net.createConnection(ziel.path)
: net.createConnection({ host: ziel.host, port: ziel.port })
}
export type ScanResult =
| { ok: true; clean: true }
| { ok: true; clean: false; signature: string }
/** Scanner nicht erreichbar, Zeitüberschreitung, unverständliche Antwort. */
| { ok: false; reason: string }
const CHUNK = 64 * 1024
export function scanBuffer(
address: string,
content: Uint8Array,
timeoutMs = 30_000,
): Promise<ScanResult> {
return new Promise((resolve) => {
let settled = false
let antwort = ''
const socket = verbinden(address)
const done = (result: ScanResult) => {
if (settled) return
settled = true
socket.destroy()
resolve(result)
}
socket.setTimeout(timeoutMs)
socket.on('timeout', () => done({ ok: false, reason: 'Zeitüberschreitung beim Scannen' }))
socket.on('error', (error) =>
done({ ok: false, reason: `clamd nicht erreichbar: ${error.message}` }),
)
socket.on('connect', () => {
socket.write('zINSTREAM\0')
for (let offset = 0; offset < content.length; offset += CHUNK) {
const teil = content.subarray(offset, offset + CHUNK)
const laenge = Buffer.alloc(4)
laenge.writeUInt32BE(teil.length, 0)
socket.write(laenge)
socket.write(teil)
}
socket.write(Buffer.from([0, 0, 0, 0]))
})
socket.on('data', (chunk) => {
antwort += chunk.toString('utf8')
})
socket.on('close', () => {
const text = antwort.replace(/\0/g, '').trim()
if (text.endsWith('OK')) return done({ ok: true, clean: true })
if (text.endsWith('FOUND')) {
const signature = text.replace(/^stream:\s*/, '').replace(/\s*FOUND$/, '')
return done({ ok: true, clean: false, signature })
}
// "size limit exceeded", "ERROR" oder gar nichts.
done({ ok: false, reason: text || 'keine Antwort von clamd' })
})
})
}
/** Fragt clamd, ob er da ist. Antwort auf zPING ist "PONG". */
export function pingClamav(address: string, timeoutMs = 2000): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
const done = (ok: boolean) => {
if (settled) return
settled = true
socket.destroy()
resolve(ok)
}
const socket = verbinden(address)
socket.setTimeout(timeoutMs)
socket.on('connect', () => socket.write('zPING\0'))
socket.on('data', (chunk) => done(chunk.toString().includes('PONG')))
socket.on('timeout', () => done(false))
socket.on('error', () => done(false))
})
}