sealcode 0.3.0 → 1.1.1

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.
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Watermarking: inject a per-filetype, traceable comment block at the top
5
+ * of every unlocked source file. When a watermarked file leaks (pasted
6
+ * into a Slack DM, posted to pastebin, indexed by an AI scraper), the
7
+ * comment travels with it — making the leak attributable to a specific
8
+ * grant + recipient + day.
9
+ *
10
+ * Design:
11
+ * - The watermark is rendered from a template the OWNER picks at
12
+ * `sealcode share` time. Placeholders: {email} {grantId}
13
+ * {projectName} {date} {fingerprint}.
14
+ * - We bracket the rendered watermark with sentinel lines so re-lock
15
+ * can strip it back out idempotently:
16
+ * <COMMENT> ==== sealcode watermark — do not remove ====
17
+ * <COMMENT> Licensed to bob@x.com · grant gnt_abc · 2026-05-20
18
+ * <COMMENT> ==== /sealcode watermark ====
19
+ * - On re-lock we re-strip — so the contractor never sees stacked
20
+ * watermarks even if they unlock/lock repeatedly.
21
+ * - Files whose syntax we don't recognize are left untouched. Better
22
+ * to ship a clean file than to break someone's binary asset.
23
+ *
24
+ * The watermark adds 3 lines per file. For source code that's nothing;
25
+ * for assets/binaries we skip entirely.
26
+ */
27
+
28
+ const path = require('path');
29
+
30
+ const SENTINEL_OPEN = '==== sealcode watermark — do not remove ====';
31
+ const SENTINEL_CLOSE = '==== /sealcode watermark ====';
32
+
33
+ /**
34
+ * Comment-syntax registry, keyed by lowercased extension WITHOUT the dot.
35
+ * Each entry is one of:
36
+ * { line: '//' } → line-comment language
37
+ * { open: '<!--', close: '-->' } → block-only language
38
+ * { line: '#' } → shell / python / yaml / etc.
39
+ * { line: '--' } → sql / haskell
40
+ * { line: ';' } → ini / lisp / asm
41
+ *
42
+ * Unknown extensions are skipped (watermark not applied).
43
+ */
44
+ const SYNTAX = {
45
+ // Curly-brace family
46
+ js: { line: '//' }, mjs: { line: '//' }, cjs: { line: '//' },
47
+ jsx: { line: '//' }, ts: { line: '//' }, tsx: { line: '//' },
48
+ go: { line: '//' }, rs: { line: '//' },
49
+ c: { line: '//' }, h: { line: '//' }, cc: { line: '//' },
50
+ cpp: { line: '//' }, hpp: { line: '//' },
51
+ cs: { line: '//' }, java: { line: '//' }, kt: { line: '//' },
52
+ swift: { line: '//' }, scala: { line: '//' }, dart: { line: '//' },
53
+ groovy: { line: '//' }, gradle: { line: '//' },
54
+ php: { line: '//' },
55
+
56
+ // Hash-comment family
57
+ py: { line: '#' }, rb: { line: '#' }, pl: { line: '#' }, pm: { line: '#' },
58
+ sh: { line: '#' }, bash: { line: '#' }, zsh: { line: '#' }, fish: { line: '#' },
59
+ yaml: { line: '#' }, yml: { line: '#' }, toml: { line: '#' },
60
+ conf: { line: '#' }, env: { line: '#' }, dockerfile: { line: '#' },
61
+ makefile: { line: '#' }, mk: { line: '#' },
62
+ r: { line: '#' }, ex: { line: '#' }, exs: { line: '#' },
63
+ nim: { line: '#' },
64
+
65
+ // Double-dash
66
+ sql: { line: '--' }, hs: { line: '--' }, lua: { line: '--' },
67
+
68
+ // Semicolon
69
+ ini: { line: ';' }, lisp: { line: ';' }, asm: { line: ';' },
70
+
71
+ // Block-only
72
+ html: { open: '<!--', close: '-->' },
73
+ htm: { open: '<!--', close: '-->' },
74
+ xml: { open: '<!--', close: '-->' },
75
+ svg: { open: '<!--', close: '-->' },
76
+ vue: { open: '<!--', close: '-->' },
77
+ css: { open: '/*', close: '*/' },
78
+ scss: { open: '/*', close: '*/' },
79
+ sass: { open: '/*', close: '*/' },
80
+ less: { open: '/*', close: '*/' },
81
+ };
82
+
83
+ function syntaxFor(filePath) {
84
+ const base = path.basename(filePath).toLowerCase();
85
+ // Dotfile? .env → "env"; .bashrc → "bash"
86
+ if (base.startsWith('.')) {
87
+ const noDot = base.slice(1).split('.')[0];
88
+ if (SYNTAX[noDot]) return SYNTAX[noDot];
89
+ }
90
+ // Magic basename (no extension): Dockerfile, Makefile
91
+ if (SYNTAX[base]) return SYNTAX[base];
92
+ const ext = path.extname(base).slice(1);
93
+ return SYNTAX[ext] || null;
94
+ }
95
+
96
+ /**
97
+ * Render the placeholder string against the per-recipient context.
98
+ * Unknown placeholders are left as literal text — the owner can use them
99
+ * for non-watermark content if they want.
100
+ */
101
+ function renderTemplate(template, ctx) {
102
+ if (!template) return '';
103
+ return String(template).replace(/\{(\w+)\}/g, (match, key) =>
104
+ Object.prototype.hasOwnProperty.call(ctx, key) ? String(ctx[key] ?? '') : match,
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Build the watermark text block for a given syntax. Returns the bytes
110
+ * to PREPEND to a source file's plaintext. Returns null if the syntax
111
+ * is unknown (caller should skip the file).
112
+ *
113
+ * Idempotency invariant: stripWatermark(applyWatermark(x, ...)) === x
114
+ * for any x that doesn't already contain our sentinels.
115
+ */
116
+ function buildWatermarkBlock(template, ctx, syntax) {
117
+ const rendered = renderTemplate(template, ctx);
118
+ if (!syntax) return null;
119
+ const lines = [];
120
+ const body = [SENTINEL_OPEN, ...rendered.split(/\r?\n/), SENTINEL_CLOSE];
121
+ if (syntax.line) {
122
+ for (const l of body) lines.push(`${syntax.line} ${l}`);
123
+ } else {
124
+ lines.push(syntax.open);
125
+ for (const l of body) lines.push(` ${l}`);
126
+ lines.push(syntax.close);
127
+ }
128
+ return lines.join('\n') + '\n';
129
+ }
130
+
131
+ /**
132
+ * Apply the watermark to plaintext. Strips any pre-existing watermark
133
+ * block first to keep us idempotent across repeat unlock/lock cycles.
134
+ *
135
+ * @param {Buffer|string} plaintext file contents
136
+ * @param {string} filePath relative path (drives syntax detection)
137
+ * @param {string} template watermark template (with {placeholders})
138
+ * @param {object} ctx substitutions
139
+ * @returns {Buffer} plaintext with watermark prepended, OR the original
140
+ * plaintext unchanged if we can't watermark (binary
141
+ * file / unknown syntax).
142
+ */
143
+ function applyWatermark(plaintext, filePath, template, ctx) {
144
+ const syntax = syntaxFor(filePath);
145
+ if (!syntax) return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
146
+ // Heuristic binary detection: any NUL byte in the first 8KB → skip.
147
+ // Avoids corrupting PNGs, .wasm, etc. if they slip past extension check.
148
+ const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
149
+ const sniff = buf.subarray(0, Math.min(8192, buf.length));
150
+ if (sniff.includes(0)) return buf;
151
+
152
+ const stripped = stripWatermark(buf, filePath);
153
+ const block = buildWatermarkBlock(template, ctx, syntax);
154
+ if (!block) return stripped;
155
+ return Buffer.concat([Buffer.from(block, 'utf8'), stripped]);
156
+ }
157
+
158
+ /**
159
+ * Remove any sealcode watermark block we previously injected. Safe to
160
+ * call on un-watermarked files — they pass through unchanged. Called
161
+ * by the lock pipeline so the encrypted blob never persists a watermark
162
+ * (which would double up on next unlock).
163
+ *
164
+ * NB: we work with BYTE offsets on the Buffer (not string offsets)
165
+ * because the sentinel contains a UTF-8 em-dash (`—`, 3 bytes) — using
166
+ * String#indexOf would return a char offset that doesn't line up with
167
+ * Buffer slicing on multi-byte text.
168
+ */
169
+ function stripWatermark(plaintext, filePath) {
170
+ const syntax = syntaxFor(filePath);
171
+ const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
172
+ if (!syntax) return buf;
173
+ // Watermark is always at the very top — bound the search to ~4 KB.
174
+ const search = buf.subarray(0, Math.min(4096, buf.length));
175
+ const openBuf = Buffer.from(SENTINEL_OPEN, 'utf8');
176
+ const closeBuf = Buffer.from(SENTINEL_CLOSE, 'utf8');
177
+ const openIdx = search.indexOf(openBuf);
178
+ if (openIdx === -1) return buf;
179
+ const closeIdx = search.indexOf(closeBuf, openIdx + openBuf.length);
180
+ if (closeIdx === -1) return buf;
181
+ // End of the line containing the close sentinel.
182
+ let lineEnd = search.indexOf(0x0a, closeIdx); // '\n'
183
+ if (lineEnd === -1) lineEnd = search.length - 1;
184
+ let after = lineEnd + 1;
185
+ // For block-comment syntaxes, also swallow the closing marker on the
186
+ // following line (e.g. `*/` for css, `-->` for html).
187
+ if (syntax.close && !syntax.line) {
188
+ const closeMarker = Buffer.from(syntax.close, 'utf8');
189
+ // Trim leading whitespace bytes for matching
190
+ let scan = after;
191
+ while (scan < buf.length && (buf[scan] === 0x20 || buf[scan] === 0x09)) scan += 1;
192
+ if (
193
+ scan < buf.length &&
194
+ buf.subarray(scan, scan + closeMarker.length).equals(closeMarker)
195
+ ) {
196
+ let nlAfter = buf.indexOf(0x0a, scan + closeMarker.length);
197
+ if (nlAfter === -1) nlAfter = buf.length - 1;
198
+ after = nlAfter + 1;
199
+ }
200
+ }
201
+ return buf.subarray(after);
202
+ }
203
+
204
+ module.exports = {
205
+ SENTINEL_OPEN,
206
+ SENTINEL_CLOSE,
207
+ syntaxFor,
208
+ renderTemplate,
209
+ buildWatermarkBlock,
210
+ applyWatermark,
211
+ stripWatermark,
212
+ };