nx_safe_archive_ingest.nx source
↩ module page · 350 lines · 18619 B
1// nx_safe_archive_ingest.nx -- SAFE sovereign ingestion of an UNTRUSTED .zip archive into the
2// permanent library. Composes the shipped zip + deflate primitives (rule 15 DRY) under a
3// NEVER-POISON-THE-LIBRARY safety envelope -- the #26 NEVER-BRICK law applied to ingestion:
4// an untrusted archive can NEVER, BY CONSTRUCTION:
5// (1) write outside the dest dir -- Zip-Slip-proof: the stored path is NEVER used as a
6// filesystem path; output is dest + "/" + a sanitized
7// BASENAME (chars mapped to [A-Za-z0-9._-], ".." rejected).
8// (2) exhaust memory/disk -- zip-bomb caps: per-entry uncompressed cap, total cap,
9// compression-ratio cap, entry-count cap, archive cap.
10// (3) be executed -- data-only: files written 0644 (no exec bit), content is
11// sanitized to printable text; nothing is ever run/chmod+x.
12// (4) smuggle an unknown codec -- method whitelist: 0 (stored) / 8 (deflate) only.
13// (5) smuggle a binary/script as library -- content-type whitelist by extension; non-text SKIPPED
14// (never banked); even "text" is stripped of control bytes
15// (defuses terminal-escape injection).
16// (6) link out via symlink -- entries with unix S_IFLNK mode are SKIPPED.
17// (7) bomb via nested archive -- we do NOT recurse; a .zip entry is non-text -> SKIPPED.
18// Zip64-sentinel sizes are SKIPPED (not yet handled = refuse, never guess).
19//
20// usage: nx_safe_archive_ingest <archive.zip> [dest-dir] (default dest = knowledge/library)
21// exit 0 always on a well-formed run (per-entry verdicts on stdout); nonzero only on unreadable
22// archive / not-a-zip. Rule 11: every cap is a named const (data-driven). license_tier: ORIGINAL
23import "nx_syscalls.nx"
24import "nx_zip_header.nx"
25import "nx_deflate.nx"
26import "nx_pdf_text.nx"
27const SAI_MAGIC_2048: i64 = 2048
28const SAI_MAGIC_16777216: i64 = 16777216
29
30// ---- safety caps (data-driven; tune in one place) ----
31const SAI_MAX_ARCHIVE: i64 = 67108864 // 64 MiB total archive bytes
32const SAI_MAX_ENTRIES: i64 = 4096
33const SAI_MAX_ENTRY_USIZE: i64 = 33554432 // 32 MiB uncompressed per entry
34const SAI_MAX_TOTAL_USIZE: i64 = 268435456 // 256 MiB uncompressed total
35const SAI_MAX_RATIO: i64 = 200 // uncompressed/compressed ceiling (zip-bomb guard)
36const SAI_CDFH_SIG: i64 = 0x02014b50
37
38const SAI_BANKED: i64 = 1
39const SAI_SKIPPED: i64 = 2
40const SAI_REJECTED: i64 = 3
41
42func sai_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 }
43func sai_putn(v: i64) -> i64 {
44 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 }
45 var m: i64 = v
46 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
47 let d: *u8 = sys_mmap(24); var k: i64 = 0
48 while m > 0 { d[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
49 var j: i64 = k - 1
50 while j >= 0 { sys_write(1, ((d as i64)+j) as *u8, 1); j = j - 1 }
51 return 0
52}
53// write the raw stored name (for the audit log only -- NEVER used as a path)
54func sai_put_name(buf: *u8, off: i64, len: i64) -> i64 {
55 var i: i64 = 0
56 while i < len { let o: *u8 = sys_mmap(1); o[0] = buf[off + i]; sys_write(1, o, 1); i = i + 1 }
57 return 0
58}
59
60func sai_lc(c: i64) -> i64 { if c >= 0x41 { if c <= 0x5a { return c + 0x20 } } return c }
61
62// is byte a member of the safe basename charset [A-Za-z0-9._-] ?
63func sai_safe_char(c: i64) -> i64 {
64 if c >= 0x41 { if c <= 0x5a { return 1 } } // A-Z
65 if c >= 0x61 { if c <= 0x7a { return 1 } } // a-z
66 if c >= 0x30 { if c <= 0x39 { return 1 } } // 0-9
67 if c == 0x2e { return 1 } // .
68 if c == 0x5f { return 1 } // _
69 if c == 0x2d { return 1 } // -
70 return 0
71}
72
73// ZIP-SLIP-PROOF basename: take ONLY the segment after the last '/' or '\', map every char NOT in
74// [A-Za-z0-9._-] to '_', reject empty / "." / "..". Returns out length, or 0 to reject.
75func sai_safe_basename(name: *u8, off: i64, len: i64, out: *u8, cap: i64) -> i64 {
76 if len <= 0 { return 0 }
77 var start: i64 = 0
78 var i: i64 = 0
79 while i < len {
80 let c: i64 = name[off + i] as i64
81 if c == 0x2f { start = i + 1 } // '/'
82 if c == 0x5c { start = i + 1 } // '\'
83 i = i + 1
84 }
85 var blen: i64 = len - start
86 if blen <= 0 { return 0 }
87 if blen > cap - 1 { blen = cap - 1 }
88 var o: i64 = 0
89 var j: i64 = 0
90 while j < blen {
91 var c: i64 = name[off + start + j] as i64
92 if sai_safe_char(c) == 0 { c = 0x5f } // map anything unsafe -> '_'
93 out[o] = c as u8
94 o = o + 1
95 j = j + 1
96 }
97 out[o] = 0 as u8
98 if o == 0 { return 0 }
99 if o == 1 { if out[0] == (0x2e as u8) { return 0 } } // "."
100 if o == 2 { if out[0] == (0x2e as u8) { if out[1] == (0x2e as u8) { return 0 } } } // ".."
101 return o
102}
103
104// case-insensitive: does base[start..end) equal NUL-terminated lit (lit lowercase) exactly?
105func sai_ext_eq(b: *u8, start: i64, end: i64, lit: *u8) -> i64 {
106 var i: i64 = 0
107 while lit[i] != (0 as u8) {
108 if start + i >= end { return 0 }
109 if sai_lc(b[start + i] as i64) != (lit[i] as i64) { return 0 }
110 i = i + 1
111 }
112 if start + i != end { return 0 }
113 return 1
114}
115
116// SKIP editor/OS cruft (AppleDouble "._*") -- never real standard content.
117func sai_name_is_cruft(b: *u8, blen: i64) -> i64 {
118 if blen >= 2 { if b[0] == (0x2e as u8) { if b[1] == (0x5f as u8) { return 1 } } }
119 return 0
120}
121// SKIP-LIST of execution-associated AND known-binary/media extensions. Execution types are skipped
122// for defense-in-depth (we never execute anything regardless); binary/media types are skipped pre-
123// inflate for efficiency. Anything NOT on this list is inflated and content-sniffed, so genuine
124// source/text (e.g. the .vhdl standard library) is captured while blobs are refused.
125func sai_is_skip_ext(b: *u8, blen: i64) -> i64 {
126 var dot: i64 = 0 - 1
127 var i: i64 = 0
128 while i < blen { if b[i] == (0x2e as u8) { dot = i } i = i + 1 }
129 if dot < 0 { return 0 }
130 let s: i64 = dot + 1
131 // executables / scripts
132 if sai_ext_eq(b, s, blen, "sh" as *u8) == 1 { return 1 }
133 if sai_ext_eq(b, s, blen, "bash" as *u8) == 1 { return 1 }
134 if sai_ext_eq(b, s, blen, "zsh" as *u8) == 1 { return 1 }
135 if sai_ext_eq(b, s, blen, "bat" as *u8) == 1 { return 1 }
136 if sai_ext_eq(b, s, blen, "cmd" as *u8) == 1 { return 1 }
137 if sai_ext_eq(b, s, blen, "com" as *u8) == 1 { return 1 }
138 if sai_ext_eq(b, s, blen, "ps1" as *u8) == 1 { return 1 }
139 if sai_ext_eq(b, s, blen, "vbs" as *u8) == 1 { return 1 }
140 if sai_ext_eq(b, s, blen, "exe" as *u8) == 1 { return 1 }
141 if sai_ext_eq(b, s, blen, "dll" as *u8) == 1 { return 1 }
142 if sai_ext_eq(b, s, blen, "scr" as *u8) == 1 { return 1 }
143 if sai_ext_eq(b, s, blen, "msi" as *u8) == 1 { return 1 }
144 if sai_ext_eq(b, s, blen, "jar" as *u8) == 1 { return 1 }
145 if sai_ext_eq(b, s, blen, "so" as *u8) == 1 { return 1 }
146 if sai_ext_eq(b, s, blen, "dylib" as *u8) == 1 { return 1 }
147 // binary / media / archives (skip pre-inflate). NOTE: pdf is NOT here -- PDFs are text-extracted
148 // by nx_pdf_text (sovereign) and banked as text, not skipped.
149 if sai_ext_eq(b, s, blen, "png" as *u8) == 1 { return 1 }
150 if sai_ext_eq(b, s, blen, "jpg" as *u8) == 1 { return 1 }
151 if sai_ext_eq(b, s, blen, "jpeg" as *u8) == 1 { return 1 }
152 if sai_ext_eq(b, s, blen, "gif" as *u8) == 1 { return 1 }
153 if sai_ext_eq(b, s, blen, "bmp" as *u8) == 1 { return 1 }
154 if sai_ext_eq(b, s, blen, "ico" as *u8) == 1 { return 1 }
155 if sai_ext_eq(b, s, blen, "zip" as *u8) == 1 { return 1 }
156 if sai_ext_eq(b, s, blen, "gz" as *u8) == 1 { return 1 }
157 if sai_ext_eq(b, s, blen, "tar" as *u8) == 1 { return 1 }
158 if sai_ext_eq(b, s, blen, "7z" as *u8) == 1 { return 1 }
159 if sai_ext_eq(b, s, blen, "rar" as *u8) == 1 { return 1 }
160 if sai_ext_eq(b, s, blen, "bin" as *u8) == 1 { return 1 }
161 if sai_ext_eq(b, s, blen, "doc" as *u8) == 1 { return 1 }
162 if sai_ext_eq(b, s, blen, "docx" as *u8) == 1 { return 1 }
163 if sai_ext_eq(b, s, blen, "xls" as *u8) == 1 { return 1 }
164 if sai_ext_eq(b, s, blen, "xlsx" as *u8) == 1 { return 1 }
165 return 0
166}
167// Does the safe basename end in ".pdf"? (PDFs are routed to nx_pdf_text for sovereign text extraction.)
168func sai_ext_is_pdf(b: *u8, blen: i64) -> i64 {
169 var dot: i64 = 0 - 1
170 var i: i64 = 0
171 while i < blen { if b[i] == (0x2e as u8) { dot = i } i = i + 1 }
172 if dot < 0 { return 0 }
173 if sai_ext_eq(b, dot + 1, blen, "pdf" as *u8) == 1 { return 1 }
174 return 0
175}
176// CONTENT SNIFF: bank only if the decompressed bytes are predominantly text. "Hard-binary" bytes
177// (NUL + C0 controls except tab/newline/CR) almost never occur in source/text but saturate PDFs/
178// images/executables. >1% hard-binary -> treat as a blob -> SKIP (never bank a binary).
179func sai_is_text_content(c: *u8, len: i64) -> i64 {
180 if len <= 0 { return 0 }
181 var bad: i64 = 0
182 var i: i64 = 0
183 while i < len {
184 let x: i64 = c[i] as i64
185 if x < 0x20 {
186 if x != 0x09 { if x != 0x0a { if x != 0x0d { bad = bad + 1 } } }
187 }
188 i = i + 1
189 }
190 if (bad * 100) > len { return 0 }
191 return 1
192}
193
194func sai_scat(dst: *u8, o: i64, s: *u8, slen: i64) -> i64 { var i: i64 = 0; while i < slen { dst[o + i] = s[i]; i = i + 1 } return o + i }
195func sai_strlen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
196
197// sanitize content to PRINTABLE text (keep 0x20-0x7e, \t \n \r; drop everything else) and write it
198// 0644 to dest/<base>. The output path is OUR controlled (dest) + sanitized basename: an entry named
199// "../../etc/passwd" can only ever land at dest/passwd. No exec bit, ever.
200func sai_bank_text(dest: *u8, destlen: i64, base: *u8, blen: i64, content: *u8, clen: i64) -> i64 {
201 let path: *u8 = sys_mmap(SAI_MAGIC_2048)
202 var o: i64 = sai_scat(path, 0, dest, destlen)
203 path[o] = 0x2f as u8; o = o + 1
204 o = sai_scat(path, o, base, blen)
205 path[o] = 0 as u8
206 let txt: *u8 = sys_mmap(clen + 16)
207 var ti: i64 = 0
208 var i: i64 = 0
209 while i < clen {
210 let c: i64 = content[i] as i64
211 var keep: i64 = 0
212 if c >= 0x20 { if c <= 0x7e { keep = 1 } }
213 if c == 0x09 { keep = 1 }
214 if c == 0x0a { keep = 1 }
215 if c == 0x0d { keep = 1 }
216 if keep == 1 { txt[ti] = c as u8; ti = ti + 1 }
217 i = i + 1
218 }
219 let fd: i64 = sys_openat_wr(path, 0x1a4) // 0644 -- NO execute bit
220 if fd < 0 { return 0 }
221 var off: i64 = 0
222 while off < ti { let w: i64 = sys_write(fd, ((txt as i64) + off) as *u8, ti - off); if w <= 0 { sys_close(fd); return 0 } off = off + w }
223 sys_close(fd)
224 return 1
225}
226
227// process ONE central-directory entry at CDFH offset `off`. Returns a SAI_* verdict; updates
228// running total via tot_p. Every dangerous shape returns SKIPPED/REJECTED BEFORE any write/inflate.
229func sai_process_entry(buf: *u8, n: i64, off: i64, dest: *u8, destlen: i64, tot_p: *i64) -> i64 {
230 let method: i64 = zip_read_u16(buf, off + 10)
231 let csize: i64 = zip_read_u32(buf, off + 20)
232 let usize: i64 = zip_read_u32(buf, off + 24)
233 let nlen: i64 = zip_read_u16(buf, off + 28)
234 let elen: i64 = zip_read_u16(buf, off + 30)
235 let clen: i64 = zip_read_u16(buf, off + 32)
236 let ext_attr: i64 = zip_read_u32(buf, off + 38)
237 let lfh_off: i64 = zip_read_u32(buf, off + 42)
238 let name_off: i64 = off + 46
239
240 // (6) symlink (unix S_IFLNK = 0xA000 in the high word of external attrs)
241 let umode: i64 = (ext_attr >> 16) & 0xffff
242 if (umode & 0xf000) == 0xa000 { sai_puts(" SKIP symlink name="); sai_put_name(buf, name_off, nlen); sai_puts("\n"); return SAI_SKIPPED }
243 // refuse Zip64-sentinel sizes (never guess a size)
244 if usize == ZIP64_MARKER { sai_puts(" SKIP zip64-usize\n"); return SAI_SKIPPED }
245 if csize == ZIP64_MARKER { sai_puts(" SKIP zip64-csize\n"); return SAI_SKIPPED }
246 // (4) method whitelist: stored(0) / deflate(8)
247 if method != 0 { if method != 8 { sai_puts(" SKIP method="); sai_putn(method); sai_puts("\n"); return SAI_SKIPPED } }
248 // (2) zip-bomb caps
249 if usize > SAI_MAX_ENTRY_USIZE { sai_puts(" REJECT entry-too-big usize="); sai_putn(usize); sai_puts("\n"); return SAI_REJECTED }
250 if (tot_p[0] + usize) > SAI_MAX_TOTAL_USIZE { sai_puts(" REJECT total-too-big\n"); return SAI_REJECTED }
251 if csize > 0 { if (usize / csize) > SAI_MAX_RATIO { sai_puts(" REJECT ratio="); sai_putn(usize / csize); sai_puts(":1 (zip-bomb)\n"); return SAI_REJECTED }
252 }
253 // (1) Zip-Slip-proof basename
254 let base: *u8 = sys_mmap(512)
255 let blen: i64 = sai_safe_basename(buf, name_off, nlen, base, 512)
256 if blen == 0 { sai_puts(" SKIP unsafe/empty-name raw="); sai_put_name(buf, name_off, nlen); sai_puts("\n"); return SAI_SKIPPED }
257 // (5a) skip OS/editor cruft + execution/binary extensions BEFORE inflating
258 if sai_name_is_cruft(base, blen) == 1 { sai_puts(" SKIP cruft name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
259 if sai_is_skip_ext(base, blen) == 1 { sai_puts(" SKIP binary/exec-ext name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
260 // locate the payload via the LFH (authoritative header length)
261 let lh_raw: *u8 = sys_mmap(128)
262 let lh: *ZipLocalHeader = lh_raw as *ZipLocalHeader
263 if zip_parse_lfh(buf, n, lfh_off, lh) != 0 { sai_puts(" SKIP bad-LFH name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
264 let payload_off: i64 = lfh_off + lh.header_len
265 if (payload_off + csize) > n { sai_puts(" SKIP payload-OOB name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
266 // decompress (bounded by usize, already <= cap)
267 var content: *u8 = 0 as *u8
268 var got: i64 = 0
269 if method == 0 {
270 content = ((buf as i64) + payload_off) as *u8
271 got = csize
272 } else {
273 let res: *NxDeflateResult = nx_deflate_inflate(((buf as i64) + payload_off) as *u8, csize, usize + 16)
274 if (res as i64) == 0 { sai_puts(" SKIP inflate-null name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
275 if res.error_code != NX_DEF_OK { sai_puts(" SKIP inflate-err name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
276 content = res.output_data
277 got = res.output_size
278 }
279 if got > usize { got = usize }
280 tot_p[0] = tot_p[0] + got
281 // PDF -> sovereign TEXT extraction (nx_pdf_text). Bank the extracted text, NEVER the binary blob.
282 // The text still passes the content sniff + sanitize + 0644 write, so no safety invariant changes.
283 if sai_ext_is_pdf(base, blen) == 1 {
284 let ptxt: *u8 = sys_mmap(SAI_MAGIC_16777216)
285 let plen: i64 = nx_pdf_extract_text(content, got, ptxt, SAI_MAGIC_16777216)
286 if plen <= 0 { sai_puts(" SKIP pdf-no-text name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
287 content = ptxt
288 got = plen
289 }
290 // (5b) content sniff -- only predominantly-text payloads are banked; binary blobs refused
291 if sai_is_text_content(content, got) == 0 { sai_puts(" SKIP binary-content name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
292 if sai_bank_text(dest, destlen, base, blen, content, got) == 0 { sai_puts(" SKIP write-fail name="); sai_puts(base); sai_puts("\n"); return SAI_SKIPPED }
293 sai_puts(" BANK "); sai_puts(base); sai_puts(" ("); sai_putn(got); sai_puts(" bytes text)\n")
294 return SAI_BANKED
295}
296
297func main(argc: i64, argv: *i64) -> i64 {
298 if argc < 2 { sai_puts("usage: nx_safe_archive_ingest <archive.zip> [dest-dir]\n"); return 1 }
299 let zippath: *u8 = argv[1] as *u8
300 var dest: *u8 = "knowledge/library" as *u8
301 if argc >= 3 { dest = argv[2] as *u8 }
302 let destlen: i64 = sai_strlen(dest)
303
304 let lb: *i64 = sys_mmap(16) as *i64
305 let buf: *u8 = sys_read_file(zippath, lb)
306 if (buf as i64) == 0 { sai_puts("REJECT: cannot read archive\n"); return 1 }
307 let n: i64 = lb[0]
308 sai_puts("nx_safe_archive_ingest: "); sai_puts(zippath); sai_puts(" bytes="); sai_putn(n); sai_puts(" dest="); sai_puts(dest); sai_puts("\n")
309 if n < 22 { sai_puts("REJECT: too short to be a zip\n"); return 1 }
310 if n > SAI_MAX_ARCHIVE { sai_puts("REJECT: archive exceeds cap\n"); return 1 }
311
312 let eocd_off: i64 = zip_find_eocd(buf, n)
313 if eocd_off < 0 { sai_puts("REJECT: no EOCD (not a zip)\n"); return 1 }
314 let e_raw: *u8 = sys_mmap(128)
315 let e: *ZipEndOfCentralDir = e_raw as *ZipEndOfCentralDir
316 if zip_parse_eocd(buf, n, eocd_off, e) != 0 { sai_puts("REJECT: bad EOCD\n"); return 1 }
317 let entries: i64 = e.entries_total
318 if entries > SAI_MAX_ENTRIES { sai_puts("REJECT: too many entries\n"); return 1 }
319 if entries < 0 { sai_puts("REJECT: bad entry count\n"); return 1 }
320 sai_puts("entries="); sai_putn(entries); sai_puts(" cd_offset="); sai_putn(e.cd_offset); sai_puts("\n")
321 sys_mkdir(dest, 0x1ed)
322
323 var off: i64 = e.cd_offset
324 var idx: i64 = 0
325 var banked: i64 = 0
326 var skipped: i64 = 0
327 var rejected: i64 = 0
328 let tot_p: *i64 = sys_mmap(16) as *i64
329 tot_p[0] = 0
330 while idx < entries {
331 if (off + 46) > n { sai_puts(" STOP: CDFH out of bounds\n"); rejected = rejected + 1; idx = entries }
332 else {
333 if zip_read_u32(buf, off) != SAI_CDFH_SIG { sai_puts(" STOP: bad CDFH sig\n"); rejected = rejected + 1; idx = entries }
334 else {
335 let nlen: i64 = zip_read_u16(buf, off + 28)
336 let elen: i64 = zip_read_u16(buf, off + 30)
337 let clen: i64 = zip_read_u16(buf, off + 32)
338 let v: i64 = sai_process_entry(buf, n, off, dest, destlen, tot_p)
339 if v == SAI_BANKED { banked = banked + 1 }
340 if v == SAI_SKIPPED { skipped = skipped + 1 }
341 if v == SAI_REJECTED { rejected = rejected + 1 }
342 off = off + 46 + nlen + elen + clen
343 idx = idx + 1
344 }
345 }
346 }
347 sai_puts("DONE: banked="); sai_putn(banked); sai_puts(" skipped="); sai_putn(skipped); sai_puts(" rejected="); sai_putn(rejected)
348 sai_puts(" total_text_bytes="); sai_putn(tot_p[0]); sai_puts("\n")
349 return 0
350}