@fabrika/app 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// The archive check of the scan pass. ClamAV answers OK for a ZIP whose entries it skipped (LZMA, a damaged deflate
|
|
2
|
+
// stream), so its OK cannot vouch for an archive. Before a scanning collection's file reaches the scanner, the
|
|
3
|
+
// framework reads every ZIP itself — an Office Open XML document is one — and admits only what ClamAV reads in full:
|
|
4
|
+
// stored or deflated entries, no encryption, a layout in which every byte belongs to an entry or the directory, sizes
|
|
5
|
+
// and CRCs that match, bounded counts and sizes, and nested ZIPs to a small depth. Any other archive is refused.
|
|
6
|
+
|
|
7
|
+
import { inflateRawSync } from 'node:zlib'
|
|
8
|
+
|
|
9
|
+
export const ARCHIVE_LIMITS = {
|
|
10
|
+
/** Entries of one archive, nested ones counted with their parent's. */
|
|
11
|
+
maxEntries: 1000,
|
|
12
|
+
/** Uncompressed bytes of one archive and everything nested in it. */
|
|
13
|
+
maxUnpackedBytes: 256 * 1024 * 1024,
|
|
14
|
+
/** The outer archive is depth 0; an archive inside it is 1; one inside that is refused. */
|
|
15
|
+
maxDepth: 1,
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
/** Why an archive is refused. Each is a reject, never a retry: the bytes will not change. */
|
|
19
|
+
export type ArchiveRefusal =
|
|
20
|
+
| 'encrypted'
|
|
21
|
+
| 'unsupported_method'
|
|
22
|
+
| 'zip64'
|
|
23
|
+
| 'malformed'
|
|
24
|
+
| 'too_many_entries'
|
|
25
|
+
| 'too_large_unpacked'
|
|
26
|
+
| 'nested_too_deep'
|
|
27
|
+
| 'unsupported_archive'
|
|
28
|
+
/** A ZIP signature inside a file that is no ZIP, or before a ZIP's first entry. */
|
|
29
|
+
| 'embedded_archive'
|
|
30
|
+
/** More than one end record a scanner could pick, or one whose comment does not end the file. */
|
|
31
|
+
| 'ambiguous_directory'
|
|
32
|
+
|
|
33
|
+
export type ArchiveCheck = { readonly ok: true } | { readonly ok: false; readonly refusal: ArchiveRefusal }
|
|
34
|
+
|
|
35
|
+
const LOCAL = 0x04034b50
|
|
36
|
+
const CENTRAL = 0x02014b50
|
|
37
|
+
const END = 0x06054b50
|
|
38
|
+
const DESCRIPTOR = 0x08074b50
|
|
39
|
+
const STORED = 0
|
|
40
|
+
const DEFLATED = 8
|
|
41
|
+
/** Bit 0: encrypted; bit 6: strong encryption; bit 13: the directory is encrypted. */
|
|
42
|
+
const ENCRYPTION_FLAGS = 0x0001 | 0x0040 | 0x2000
|
|
43
|
+
const HAS_DESCRIPTOR = 0x0008
|
|
44
|
+
|
|
45
|
+
/** Other archive and compression formats, by their first bytes: the scan cannot vouch for what they hold. */
|
|
46
|
+
const OTHER_ARCHIVES: readonly (readonly number[])[] = [
|
|
47
|
+
[0x1f, 0x8b], // gzip
|
|
48
|
+
[0x42, 0x5a, 0x68], // bzip2
|
|
49
|
+
[0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], // xz
|
|
50
|
+
[0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], // 7z
|
|
51
|
+
[0x52, 0x61, 0x72, 0x21, 0x1a, 0x07], // rar
|
|
52
|
+
[0x4d, 0x53, 0x43, 0x46], // cab
|
|
53
|
+
[0x28, 0xb5, 0x2f, 0xfd], // zstd
|
|
54
|
+
[0x4c, 0x5a, 0x49, 0x50], // lzip
|
|
55
|
+
[0x5d, 0x00, 0x00], // lzma
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
class Refused extends Error {
|
|
59
|
+
readonly refusal: ArchiveRefusal
|
|
60
|
+
|
|
61
|
+
constructor(refusal: ArchiveRefusal) {
|
|
62
|
+
super(refusal)
|
|
63
|
+
this.refusal = refusal
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function startsWith(bytes: Uint8Array, prefix: readonly number[], at = 0): boolean {
|
|
68
|
+
return bytes.length >= at + prefix.length && prefix.every((byte, index) => bytes[at + index] === byte)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isZip(bytes: Uint8Array): boolean {
|
|
72
|
+
return startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]) || startsWith(bytes, [0x50, 0x4b, 0x05, 0x06])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `ustar` at offset 257: a tar archive, which ClamAV opens but this check does not. */
|
|
76
|
+
function isTar(bytes: Uint8Array): boolean {
|
|
77
|
+
return startsWith(bytes, [0x75, 0x73, 0x74, 0x61, 0x72], 257)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isOtherArchive(bytes: Uint8Array): boolean {
|
|
81
|
+
return isTar(bytes) || OTHER_ARCHIVES.some((magic) => startsWith(bytes, magic))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A ZIP's local header, central header and end record: ClamAV opens a ZIP it finds by any of them, wherever it sits. */
|
|
85
|
+
const ZIP_SIGNATURES = [Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from([0x50, 0x4b, 0x01, 0x02]), Buffer.from([0x50, 0x4b, 0x05, 0x06])]
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a ZIP signature appears anywhere: in a file that is no ZIP, it is a ZIP embedded where the check does not
|
|
89
|
+
* look, so it is refused. Random bytes hold one with a chance of about three in 2^32 per byte (roughly one in 1400 for
|
|
90
|
+
* 1 MB of compressed data); such a file is rejected too, which fails closed.
|
|
91
|
+
*/
|
|
92
|
+
function containsZipSignature(bytes: Uint8Array): boolean {
|
|
93
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
94
|
+
return ZIP_SIGNATURES.some((signature) => buffer.includes(signature))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How often `signature` occurs in `bytes`. */
|
|
98
|
+
function occurrences(bytes: Uint8Array, signature: Buffer): number {
|
|
99
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
100
|
+
let count = 0
|
|
101
|
+
for (let at = buffer.indexOf(signature); at !== -1; at = buffer.indexOf(signature, at + 1)) count++
|
|
102
|
+
return count
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class Reader {
|
|
106
|
+
readonly #view: DataView
|
|
107
|
+
|
|
108
|
+
constructor(readonly bytes: Uint8Array) {
|
|
109
|
+
this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
u16(at: number): number {
|
|
113
|
+
if (at < 0 || at + 2 > this.bytes.length) throw new Refused('malformed')
|
|
114
|
+
return this.#view.getUint16(at, true)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
u32(at: number): number {
|
|
118
|
+
if (at < 0 || at + 4 > this.bytes.length) throw new Refused('malformed')
|
|
119
|
+
return this.#view.getUint32(at, true)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
slice(start: number, end: number): Uint8Array {
|
|
123
|
+
if (start < 0 || end < start || end > this.bytes.length) throw new Refused('malformed')
|
|
124
|
+
return this.bytes.subarray(start, end)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Entry {
|
|
129
|
+
readonly flags: number
|
|
130
|
+
readonly method: number
|
|
131
|
+
readonly crc: number
|
|
132
|
+
readonly compressed: number
|
|
133
|
+
readonly uncompressed: number
|
|
134
|
+
readonly name: Uint8Array
|
|
135
|
+
readonly offset: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Budget {
|
|
139
|
+
entries: number
|
|
140
|
+
unpacked: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The end-of-directory record: the only one a scanner could find in the tail, its comment ending the file exactly and
|
|
145
|
+
* holding no ZIP signature, one disk, no ZIP64. A scanner that picked another record would read another directory.
|
|
146
|
+
*/
|
|
147
|
+
function endRecord(zip: Reader): { readonly entries: number; readonly directoryStart: number; readonly directoryEnd: number } {
|
|
148
|
+
const length = zip.bytes.length
|
|
149
|
+
const tail = zip.bytes.subarray(Math.max(0, length - 22 - 0xffff))
|
|
150
|
+
if (occurrences(tail, ZIP_SIGNATURES[2] ?? Buffer.alloc(0)) !== 1) throw new Refused('ambiguous_directory')
|
|
151
|
+
for (let at = length - 22; at >= Math.max(0, length - 22 - 0xffff); at--) {
|
|
152
|
+
if (zip.u32(at) !== END) continue
|
|
153
|
+
if (at + 22 + zip.u16(at + 20) !== length) throw new Refused('ambiguous_directory')
|
|
154
|
+
if (containsZipSignature(zip.slice(at + 22, length))) throw new Refused('ambiguous_directory')
|
|
155
|
+
const entries = zip.u16(at + 10)
|
|
156
|
+
const size = zip.u32(at + 12)
|
|
157
|
+
const start = zip.u32(at + 16)
|
|
158
|
+
if (entries === 0xffff || size === 0xffffffff || start === 0xffffffff) throw new Refused('zip64')
|
|
159
|
+
if (zip.u16(at + 4) !== 0 || zip.u16(at + 6) !== 0 || zip.u16(at + 8) !== entries) throw new Refused('malformed')
|
|
160
|
+
if (start + size !== at) throw new Refused('malformed')
|
|
161
|
+
return { entries, directoryStart: start, directoryEnd: at }
|
|
162
|
+
}
|
|
163
|
+
throw new Refused('malformed')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function directory(zip: Reader, end: ReturnType<typeof endRecord>): Entry[] {
|
|
167
|
+
const entries: Entry[] = []
|
|
168
|
+
let at = end.directoryStart
|
|
169
|
+
for (let index = 0; index < end.entries; index++) {
|
|
170
|
+
if (zip.u32(at) !== CENTRAL) throw new Refused('malformed')
|
|
171
|
+
const flags = zip.u16(at + 8)
|
|
172
|
+
const method = zip.u16(at + 10)
|
|
173
|
+
const compressed = zip.u32(at + 20)
|
|
174
|
+
const uncompressed = zip.u32(at + 24)
|
|
175
|
+
const nameLength = zip.u16(at + 28)
|
|
176
|
+
const next = at + 46 + nameLength + zip.u16(at + 30) + zip.u16(at + 32)
|
|
177
|
+
if ((flags & ENCRYPTION_FLAGS) !== 0) throw new Refused('encrypted')
|
|
178
|
+
if (method !== STORED && method !== DEFLATED) throw new Refused('unsupported_method')
|
|
179
|
+
if (compressed === 0xffffffff || uncompressed === 0xffffffff || zip.u32(at + 42) === 0xffffffff) throw new Refused('zip64')
|
|
180
|
+
if (zip.u16(at + 34) !== 0 || (method === STORED && compressed !== uncompressed)) throw new Refused('malformed')
|
|
181
|
+
entries.push({
|
|
182
|
+
flags,
|
|
183
|
+
method,
|
|
184
|
+
crc: zip.u32(at + 16),
|
|
185
|
+
compressed,
|
|
186
|
+
uncompressed,
|
|
187
|
+
name: zip.slice(at + 46, at + 46 + nameLength),
|
|
188
|
+
offset: zip.u32(at + 42),
|
|
189
|
+
})
|
|
190
|
+
at = next
|
|
191
|
+
}
|
|
192
|
+
if (at !== end.directoryEnd) throw new Refused('malformed')
|
|
193
|
+
return entries
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sameBytes = (left: Uint8Array, right: Uint8Array): boolean => left.length === right.length && left.every((byte, index) => byte === right[index])
|
|
197
|
+
|
|
198
|
+
/** The entry's compressed bytes, after a local header that agrees with the directory; and where the entry ends. */
|
|
199
|
+
function localData(zip: Reader, entry: Entry): { readonly data: Uint8Array; readonly end: number } {
|
|
200
|
+
const at = entry.offset
|
|
201
|
+
if (zip.u32(at) !== LOCAL) throw new Refused('malformed')
|
|
202
|
+
const flags = zip.u16(at + 6)
|
|
203
|
+
if ((flags & ENCRYPTION_FLAGS) !== 0) throw new Refused('encrypted')
|
|
204
|
+
if (zip.u16(at + 8) !== entry.method || (flags & HAS_DESCRIPTOR) !== (entry.flags & HAS_DESCRIPTOR)) throw new Refused('malformed')
|
|
205
|
+
const nameLength = zip.u16(at + 26)
|
|
206
|
+
if (!sameBytes(zip.slice(at + 30, at + 30 + nameLength), entry.name)) throw new Refused('malformed')
|
|
207
|
+
const start = at + 30 + nameLength + zip.u16(at + 28)
|
|
208
|
+
const dataEnd = start + entry.compressed
|
|
209
|
+
const data = zip.slice(start, dataEnd)
|
|
210
|
+
if ((flags & HAS_DESCRIPTOR) === 0) {
|
|
211
|
+
if (zip.u32(at + 14) !== entry.crc || zip.u32(at + 18) !== entry.compressed || zip.u32(at + 22) !== entry.uncompressed) {
|
|
212
|
+
throw new Refused('malformed')
|
|
213
|
+
}
|
|
214
|
+
return { data, end: dataEnd }
|
|
215
|
+
}
|
|
216
|
+
// A data descriptor follows the data, with or without its signature.
|
|
217
|
+
const signed = dataEnd + 4 <= zip.bytes.length && zip.u32(dataEnd) === DESCRIPTOR
|
|
218
|
+
const fields = signed ? dataEnd + 4 : dataEnd
|
|
219
|
+
if (zip.u32(fields) !== entry.crc || zip.u32(fields + 4) !== entry.compressed || zip.u32(fields + 8) !== entry.uncompressed) {
|
|
220
|
+
throw new Refused('malformed')
|
|
221
|
+
}
|
|
222
|
+
return { data, end: fields + 12 }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function unpacked(entry: Entry, data: Uint8Array, budget: Budget): Uint8Array {
|
|
226
|
+
budget.unpacked += entry.uncompressed
|
|
227
|
+
if (budget.unpacked > ARCHIVE_LIMITS.maxUnpackedBytes) throw new Refused('too_large_unpacked')
|
|
228
|
+
let content: Uint8Array
|
|
229
|
+
if (entry.method === STORED) {
|
|
230
|
+
content = data
|
|
231
|
+
} else {
|
|
232
|
+
try {
|
|
233
|
+
// One byte more than the directory claims: a longer stream lies about its size.
|
|
234
|
+
content = inflateRawSync(data, { maxOutputLength: entry.uncompressed + 1 })
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Refused('malformed')
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (content.length !== entry.uncompressed || Bun.hash.crc32(content) !== entry.crc) throw new Refused('malformed')
|
|
240
|
+
return content
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkZip(bytes: Uint8Array, depth: number, budget: Budget): void {
|
|
244
|
+
const zip = new Reader(bytes)
|
|
245
|
+
const end = endRecord(zip)
|
|
246
|
+
const entries = directory(zip, end).sort((left, right) => left.offset - right.offset)
|
|
247
|
+
budget.entries += entries.length
|
|
248
|
+
if (budget.entries > ARCHIVE_LIMITS.maxEntries) throw new Refused('too_many_entries')
|
|
249
|
+
// Every byte belongs to an entry or the directory: nothing hides between them, before the first or after the last.
|
|
250
|
+
let expected = 0
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
if (entry.offset !== expected) throw new Refused('malformed')
|
|
253
|
+
const { data, end: entryEnd } = localData(zip, entry)
|
|
254
|
+
expected = entryEnd
|
|
255
|
+
checkContent(unpacked(entry, data, budget), depth, budget)
|
|
256
|
+
}
|
|
257
|
+
if (expected !== end.directoryStart) throw new Refused('malformed')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A nested entry's content: a ZIP from its first byte is checked in turn; any other archive or ZIP signature is refused. */
|
|
261
|
+
function checkContent(content: Uint8Array, depth: number, budget: Budget): void {
|
|
262
|
+
if (isZip(content)) {
|
|
263
|
+
if (depth + 1 > ARCHIVE_LIMITS.maxDepth) throw new Refused('nested_too_deep')
|
|
264
|
+
checkZip(content, depth + 1, budget)
|
|
265
|
+
} else if (isOtherArchive(content)) {
|
|
266
|
+
throw new Refused('unsupported_archive')
|
|
267
|
+
} else if (containsZipSignature(content)) {
|
|
268
|
+
throw new Refused('embedded_archive')
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether the scanner can vouch for these bytes. A file of a ZIP-based type (`zipBased`: ZIP and Office Open XML) must
|
|
274
|
+
* be a ZIP from its first byte whose every entry, nested ZIPs included, ClamAV reads in full. Any other file passes
|
|
275
|
+
* only when it holds no archive: no other archive format, and no ZIP signature at any offset.
|
|
276
|
+
*/
|
|
277
|
+
export function checkArchive(bytes: Uint8Array, zipBased: boolean): ArchiveCheck {
|
|
278
|
+
try {
|
|
279
|
+
if (zipBased) {
|
|
280
|
+
if (!isZip(bytes)) throw new Refused('embedded_archive')
|
|
281
|
+
checkZip(bytes, 0, { entries: 0, unpacked: 0 })
|
|
282
|
+
} else if (isOtherArchive(bytes)) {
|
|
283
|
+
throw new Refused('unsupported_archive')
|
|
284
|
+
} else if (containsZipSignature(bytes)) {
|
|
285
|
+
throw new Refused('embedded_archive')
|
|
286
|
+
}
|
|
287
|
+
return { ok: true }
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error instanceof Refused) return { ok: false, refusal: error.refusal }
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// The ClamAV scanner of the scan pass, on Bun: one `clamscan` over a batch written to a private temporary directory,
|
|
2
|
+
// so the signatures load once per batch. Its limits make every unscannable file an alert rather than a silent pass:
|
|
3
|
+
// an encrypted file, one past a size, recursion or time limit, all come back FOUND and are rejected. Signatures older
|
|
4
|
+
// than `maxSignatureAgeDays` make clamscan refuse the batch. Only an exact `<path>: OK` line is a clean verdict.
|
|
5
|
+
|
|
6
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { FILE_LIMITS } from '../contract/defaults'
|
|
10
|
+
import { type FileScanner, SCAN_LIMITS, type ScanBatchResult, type ScanFailure, type ScanObject, type ScanVerdict } from './scan'
|
|
11
|
+
|
|
12
|
+
export const CLAMSCAN_LIMITS = {
|
|
13
|
+
/** The scanner refuses signatures older than this; the provider refreshes them at start and daily. */
|
|
14
|
+
maxSignatureAgeDays: 3,
|
|
15
|
+
/** One file's scan time, after which ClamAV alerts instead of passing it. */
|
|
16
|
+
maxScanTimeMs: 120_000,
|
|
17
|
+
maxRecursion: 10,
|
|
18
|
+
maxFilesPerContainer: 1000,
|
|
19
|
+
/** What clamscan may print; past it the run is stopped and the batch gets no verdict. */
|
|
20
|
+
maxOutputBytes: 1024 * 1024,
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
24
|
+
|
|
25
|
+
export interface ClamscanOptions {
|
|
26
|
+
/** The `clamscan` binary; by default the one on `PATH`. */
|
|
27
|
+
readonly binary?: string
|
|
28
|
+
/** The signature directory; by default ClamAV's configured one. */
|
|
29
|
+
readonly database?: string
|
|
30
|
+
/** Where the batch's directory is made; by default the system's temporary directory. */
|
|
31
|
+
readonly directory?: string
|
|
32
|
+
readonly timeoutMs?: number
|
|
33
|
+
/** The largest file ClamAV reads in full; a larger one alerts. By default `FILE_LIMITS.maxScannedBytes`. */
|
|
34
|
+
readonly maxFileBytes?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The arguments of one run; every limit alerts, so a file ClamAV could not read in full is never reported OK. */
|
|
38
|
+
export function clamscanArguments(database: string | undefined, size: number = FILE_LIMITS.maxScannedBytes): readonly string[] {
|
|
39
|
+
return [
|
|
40
|
+
'--no-summary',
|
|
41
|
+
'--stdout',
|
|
42
|
+
'--alert-encrypted=yes',
|
|
43
|
+
'--alert-exceeds-max=yes',
|
|
44
|
+
`--fail-if-cvd-older-than=${CLAMSCAN_LIMITS.maxSignatureAgeDays}`,
|
|
45
|
+
`--max-filesize=${size}`,
|
|
46
|
+
`--max-scansize=${size * 4}`,
|
|
47
|
+
`--max-scantime=${CLAMSCAN_LIMITS.maxScanTimeMs}`,
|
|
48
|
+
`--max-recursion=${CLAMSCAN_LIMITS.maxRecursion}`,
|
|
49
|
+
`--max-files=${CLAMSCAN_LIMITS.maxFilesPerContainer}`,
|
|
50
|
+
...(database === undefined ? [] : [`--database=${database}`]),
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The verdicts of one finished run. Exit 0 (nothing found) and 1 (something found) carry per-file lines; any other exit
|
|
56
|
+
* is a failed batch, classified from stderr. A file without exactly one `<path>: OK` or `<path>: <name> FOUND` line,
|
|
57
|
+
* a conflicting second line included, gets no clean verdict.
|
|
58
|
+
*/
|
|
59
|
+
export function clamscanVerdicts(paths: ReadonlyMap<string, string>, exitCode: number, stdout: string, stderr: string): ScanBatchResult {
|
|
60
|
+
if (exitCode !== 0 && exitCode !== 1) {
|
|
61
|
+
// `--stdout` moves clamscan's own errors to stdout; libclamav's stay on stderr.
|
|
62
|
+
return { ok: false, failure: failureOf(`${stdout}\n${stderr}`) }
|
|
63
|
+
}
|
|
64
|
+
const verdicts = new Map<string, ScanVerdict>()
|
|
65
|
+
const seen = new Set<string>()
|
|
66
|
+
for (const line of stdout.split('\n')) {
|
|
67
|
+
for (const [id, path] of paths) {
|
|
68
|
+
const prefix = `${path}: `
|
|
69
|
+
if (!line.startsWith(prefix)) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const answer = line.slice(prefix.length)
|
|
73
|
+
const found = /^(\S+) FOUND$/.exec(answer)
|
|
74
|
+
const verdict: ScanVerdict = answer === 'OK'
|
|
75
|
+
? { kind: 'clean' }
|
|
76
|
+
: found?.[1] !== undefined
|
|
77
|
+
? { kind: 'infected', signature: found[1] }
|
|
78
|
+
: { kind: 'unscanned' }
|
|
79
|
+
verdicts.set(id, seen.has(id) ? { kind: 'unscanned' } : verdict)
|
|
80
|
+
seen.add(id)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, verdicts }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function failureOf(output: string): ScanFailure {
|
|
87
|
+
if (output.includes('Virus database is older than')) {
|
|
88
|
+
return 'signatures_stale'
|
|
89
|
+
}
|
|
90
|
+
if (output.includes('cl_load') || output.includes('No supported database files')) {
|
|
91
|
+
return 'signatures_missing'
|
|
92
|
+
}
|
|
93
|
+
return 'scanner_failed'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The stream's text, or `null` once it passes `limit` bytes (when `overflow` runs) or `halt` aborts: a process the run
|
|
98
|
+
* killed may have left a child holding the pipe open.
|
|
99
|
+
*/
|
|
100
|
+
async function bounded(stream: ReadableStream<Uint8Array>, limit: number, overflow: () => void, halt: AbortSignal): Promise<string | null> {
|
|
101
|
+
const decoder = new TextDecoder()
|
|
102
|
+
let text = ''
|
|
103
|
+
let length = 0
|
|
104
|
+
const reader = stream.getReader()
|
|
105
|
+
const halted = new Promise<null>((resolve) => halt.addEventListener('abort', () => resolve(null), { once: true }))
|
|
106
|
+
const read = () => Promise.race([reader.read(), halted])
|
|
107
|
+
for (let next = await read(); next !== null && !next.done; next = await read()) {
|
|
108
|
+
length += next.value.byteLength
|
|
109
|
+
if (length > limit) {
|
|
110
|
+
overflow()
|
|
111
|
+
await reader.cancel()
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
text += decoder.decode(next.value, { stream: true })
|
|
115
|
+
}
|
|
116
|
+
if (halt.aborted) {
|
|
117
|
+
await reader.cancel()
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
return text + decoder.decode()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `promise`'s value, or `null` once `signal` aborts first: a read that never answers does not hold the run. */
|
|
124
|
+
function unlessAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T | null> {
|
|
125
|
+
if (signal.aborted) return Promise.resolve(null)
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const stop = () => resolve(null)
|
|
128
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
129
|
+
promise.then(
|
|
130
|
+
(value) => {
|
|
131
|
+
signal.removeEventListener('abort', stop)
|
|
132
|
+
resolve(value)
|
|
133
|
+
},
|
|
134
|
+
(error: unknown) => {
|
|
135
|
+
signal.removeEventListener('abort', stop)
|
|
136
|
+
reject(error)
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Streams `body` to `path`; `false` when `signal` aborted it, which cancels the reader. A reader and a file sink:
|
|
144
|
+
* `Bun.write` of a stream that errors never settles (Bun 1.3).
|
|
145
|
+
*/
|
|
146
|
+
async function writeStream(path: string, body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<boolean> {
|
|
147
|
+
const sink = Bun.file(path).writer()
|
|
148
|
+
const reader = body.getReader()
|
|
149
|
+
try {
|
|
150
|
+
for (let next = await unlessAborted(reader.read(), signal); next !== null; next = await unlessAborted(reader.read(), signal)) {
|
|
151
|
+
if (next.done) return true
|
|
152
|
+
sink.write(next.value)
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
} finally {
|
|
156
|
+
await reader.cancel().catch(() => undefined)
|
|
157
|
+
await sink.end()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Writes each object to `<directory>/<id>`; a file whose object could not be read in full is left out. */
|
|
162
|
+
async function write(directory: string, objects: readonly ScanObject[], signal: AbortSignal): Promise<Map<string, string>> {
|
|
163
|
+
const paths = new Map<string, string>()
|
|
164
|
+
for (const object of objects) {
|
|
165
|
+
if (signal.aborted) break
|
|
166
|
+
if (!UUID.test(object.id)) continue
|
|
167
|
+
const path = join(directory, object.id)
|
|
168
|
+
try {
|
|
169
|
+
const body = await unlessAborted(object.open(signal), signal)
|
|
170
|
+
if (body !== null && (await writeStream(path, body, signal))) paths.set(object.id, path)
|
|
171
|
+
} catch {
|
|
172
|
+
await rm(path, { force: true })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return paths
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface Finished {
|
|
179
|
+
readonly exitCode: number
|
|
180
|
+
readonly stdout: string
|
|
181
|
+
readonly stderr: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One clamscan run, stopped on `signal` or once its output passes the bound; never started once `signal` aborted. */
|
|
185
|
+
async function run(binary: string, args: readonly string[], signal: AbortSignal): Promise<Finished | ScanFailure> {
|
|
186
|
+
if (signal.aborted) return 'timeout'
|
|
187
|
+
let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
|
|
188
|
+
try {
|
|
189
|
+
// Only PATH: the job's own environment carries the application's secrets.
|
|
190
|
+
child = Bun.spawn([binary, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: { PATH: process.env['PATH'] ?? '/usr/bin:/bin' } })
|
|
191
|
+
} catch {
|
|
192
|
+
return 'scanner_missing'
|
|
193
|
+
}
|
|
194
|
+
let stopped = false
|
|
195
|
+
const halt = new AbortController()
|
|
196
|
+
const stop = (): void => {
|
|
197
|
+
stopped = true
|
|
198
|
+
child.kill('SIGKILL')
|
|
199
|
+
halt.abort()
|
|
200
|
+
}
|
|
201
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
202
|
+
try {
|
|
203
|
+
let overflowed = false
|
|
204
|
+
const overflow = (): void => {
|
|
205
|
+
overflowed = true
|
|
206
|
+
child.kill('SIGKILL')
|
|
207
|
+
halt.abort()
|
|
208
|
+
}
|
|
209
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
210
|
+
bounded(child.stdout, CLAMSCAN_LIMITS.maxOutputBytes, overflow, halt.signal),
|
|
211
|
+
bounded(child.stderr, CLAMSCAN_LIMITS.maxOutputBytes, overflow, halt.signal),
|
|
212
|
+
child.exited,
|
|
213
|
+
])
|
|
214
|
+
if (overflowed) {
|
|
215
|
+
return 'output_overflow'
|
|
216
|
+
}
|
|
217
|
+
if (stopped || stdout === null || stderr === null) {
|
|
218
|
+
return 'timeout'
|
|
219
|
+
}
|
|
220
|
+
return { exitCode, stdout, stderr }
|
|
221
|
+
} finally {
|
|
222
|
+
signal.removeEventListener('abort', stop)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** ClamAV's `clamscan` as the scan pass's scanner. Fails closed: see `ScanFailure` for what makes a batch fail. */
|
|
227
|
+
export function clamscanScanner(options: ClamscanOptions = {}): FileScanner {
|
|
228
|
+
const binary = options.binary ?? 'clamscan'
|
|
229
|
+
const timeoutMs = options.timeoutMs ?? SCAN_LIMITS.scanTimeoutMs
|
|
230
|
+
return {
|
|
231
|
+
async scan(objects, signal) {
|
|
232
|
+
// One deadline over the whole run: reading the objects, writing them, the scan and the cleanup.
|
|
233
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
|
|
234
|
+
const directory = await mkdtemp(join(options.directory ?? tmpdir(), 'fabrika-scan-'))
|
|
235
|
+
try {
|
|
236
|
+
const paths = await write(directory, objects, deadline)
|
|
237
|
+
if (deadline.aborted) {
|
|
238
|
+
return { ok: false, failure: 'timeout' }
|
|
239
|
+
}
|
|
240
|
+
if (paths.size === 0) {
|
|
241
|
+
return { ok: true, verdicts: new Map() }
|
|
242
|
+
}
|
|
243
|
+
const args = [...clamscanArguments(options.database, options.maxFileBytes), ...paths.values()]
|
|
244
|
+
const finished = await run(binary, args, deadline)
|
|
245
|
+
return typeof finished === 'string'
|
|
246
|
+
? { ok: false, failure: finished }
|
|
247
|
+
: clamscanVerdicts(paths, finished.exitCode, finished.stdout, finished.stderr)
|
|
248
|
+
} finally {
|
|
249
|
+
await rm(directory, { recursive: true, force: true })
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|