nsauditor-ai 0.2.49 → 0.2.51

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,252 @@
1
+ // Loads a consultant's `brand.json` for the client-facing HTML report (Task 6 renders it).
2
+ //
3
+ // Everything in that file is untrusted text: a title, a company name, a preparer, a contact,
4
+ // and an optional logo. The report it feeds has a hard invariant — no <script> element, no
5
+ // on* attribute, no external reference except an <a href> with an http/https/mailto scheme —
6
+ // and this module's whole job is making sure a brand file cannot defeat that from the inside.
7
+ //
8
+ // Escaping happens at RENDER time (Task 6), not here: this module stores the operator's text
9
+ // as given so it is never double-escaped.
10
+
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ const MAX_LOGO_BYTES = 2 * 1024 * 1024;
15
+ const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
16
+ const JPEG = Buffer.from('ffd8ff', 'hex');
17
+
18
+ // A URI scheme prefix, e.g. "http:", "https:", "data:", "file:", "javascript:". Deliberately
19
+ // requires at least two letters before the colon so a Windows drive letter ("C:\...") does not
20
+ // collide with it — that case is instead caught by the directory-containment check below, with
21
+ // the correct message for what it actually is (a path, not a URL).
22
+ const SCHEME_RE = /^[a-zA-Z]{2,}[a-zA-Z0-9+.-]*:/;
23
+
24
+ // A leading pair of path-separator characters, in ANY combination of "/" and "\" — a
25
+ // protocol-relative URL ("//host/path") on every platform, and a UNC network-share reference
26
+ // ("\\host\share\path") on Windows. This check is deliberately INDEPENDENT of the directory-
27
+ // containment check and of the eventual fs.readFile: on a host where such a path happens not to
28
+ // resolve, containment (or a plain ENOENT) would refuse it too, but that is a refusal for the
29
+ // wrong reason — a security property that holds only because a path happened not to resolve is
30
+ // not a property. Opening a UNC path is a live SMB connection (and, historically, an
31
+ // NTLM-credential-leak vector) the instant the share exists and is reachable, which not every
32
+ // host in this product's install base can be assumed not to be. This check fires on the string
33
+ // alone, before any path resolution or filesystem call, on every platform.
34
+ const NETWORK_PATH_RE = /^[\\/]{2}/;
35
+
36
+ export function escapeHtml(s) {
37
+ return String(s ?? '')
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;');
43
+ }
44
+
45
+ // String(v) throws when v is an object whose own `toString` shadows Object.prototype.toString
46
+ // with a non-callable value and whose `valueOf` does not yield a primitive either — and
47
+ // JSON.parse can produce exactly that shape, e.g. `{"companyName": {"toString": "nope"}}`.
48
+ // A brand file must never be able to crash the loader; coerce defensively. Used for the four
49
+ // free-text metadata fields only — `logoPath` is type-checked instead of coerced (below), so it
50
+ // never reaches this function at all when it isn't already a string.
51
+ function safeString(v) {
52
+ if (v == null) return '';
53
+ if (typeof v === 'string') return v;
54
+ try { return String(v); } catch { return ''; }
55
+ }
56
+
57
+ function isPlainObject(v) {
58
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
59
+ }
60
+
61
+ // Names what `logoPath` actually was, for a refusal message — never called on a string.
62
+ function describeLogoPathType(v) {
63
+ if (Array.isArray(v)) return 'an array';
64
+ if (typeof v === 'number') return 'a number';
65
+ if (typeof v === 'boolean') return 'a boolean';
66
+ return 'an object';
67
+ }
68
+
69
+ function sniff(buf) {
70
+ if (buf.subarray(0, PNG.length).equals(PNG)) return 'image/png';
71
+ if (buf.subarray(0, JPEG.length).equals(JPEG)) return 'image/jpeg';
72
+ return null;
73
+ }
74
+
75
+ // A relative-path escape check, used twice below: once on the lexical (as-typed) path, and
76
+ // again on the realpath (symlinks resolved). `rel` is `path.relative(dir, file)`.
77
+ function escapesDir(rel) {
78
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
79
+ }
80
+
81
+ export async function loadBrand(brandPath) {
82
+ const brand = {
83
+ title: 'Network Scan Report', companyName: '', preparedBy: '', contact: '', logoDataUri: null,
84
+ };
85
+ if (!brandPath) return { ok: true, brand };
86
+
87
+ let raw;
88
+ try { raw = await fsp.readFile(brandPath, 'utf8'); }
89
+ catch (e) {
90
+ return { ok: false, message: `Could not read the brand file at ${path.basename(brandPath)}: ${e?.message || e}` };
91
+ }
92
+
93
+ let cfg;
94
+ try { cfg = JSON.parse(raw); }
95
+ catch (e) {
96
+ return { ok: false, message: `Could not parse the brand file at ${path.basename(brandPath)} as JSON: ${e?.message || e}` };
97
+ }
98
+
99
+ if (!isPlainObject(cfg)) {
100
+ return { ok: false, message: `The brand file at ${path.basename(brandPath)} must be a JSON object, not ${Array.isArray(cfg) ? 'an array' : typeof cfg}.` };
101
+ }
102
+
103
+ for (const k of ['title', 'companyName', 'preparedBy', 'contact']) {
104
+ if (cfg[k] != null) brand[k] = safeString(cfg[k]); // escaped at RENDER time, not here
105
+ }
106
+
107
+ if (cfg.logoPath != null) {
108
+ // Type-checked BEFORE anything touches the filesystem or even converts the value: a
109
+ // non-string logoPath (an object, an array, a number, a boolean) must be refused BY NAME,
110
+ // not by whatever filesystem errno its coerced-to-string form happens to trip. Driven
111
+ // measurement found three shapes refused for the wrong reason under the previous
112
+ // safeString()-based coercion: `{toString:'nope'}` refused via a stray "EISDIR" (the poison
113
+ // toString collapsed to '', which resolved to the brand directory itself), `['a']` and `42`
114
+ // refused via a stray "ENOENT" (coerced to a filename that doesn't exist) — the exact
115
+ // refused-by-accident class the UNC fix above closed one layer up, recurring here one field
116
+ // over. A refusal that only holds because the coerced string happens not to resolve to a
117
+ // real file is not a refusal by design.
118
+ if (typeof cfg.logoPath !== 'string') {
119
+ return {
120
+ ok: false,
121
+ message: `\`logoPath\` must be a string naming a local PNG or JPEG file; it is `
122
+ + `${describeLogoPathType(cfg.logoPath)}, not a string.`,
123
+ };
124
+ }
125
+
126
+ // An empty string is read the same as an absent field: "no logo provided." This is a
127
+ // DELIBERATE reading (an operator's generated brand.json may emit "" rather than omitting
128
+ // the key), not a side effect of a truthiness check — pinned by its own fixture
129
+ // (`tests/brand.test.mjs`: "an empty-string logoPath ..."). If a future maintainer wants the
130
+ // opposite reading (refuse an explicit empty string), that is a one-line change here, but it
131
+ // must keep its own fixture too.
132
+ if (cfg.logoPath !== '') {
133
+ const rawLogo = cfg.logoPath;
134
+
135
+ if (SCHEME_RE.test(rawLogo)) {
136
+ return {
137
+ ok: false,
138
+ message: '`logoPath` names a URL, which is refused and never fetched. A remote logo is '
139
+ + 'egress at render time and a tracking pixel every time the client opens the file. '
140
+ + 'Point `logoPath` at a local PNG or JPEG next to the brand file.',
141
+ };
142
+ }
143
+ if (NETWORK_PATH_RE.test(rawLogo)) {
144
+ return {
145
+ ok: false,
146
+ message: '`logoPath` names a network path — a UNC share reference (\\\\host\\share\\..., '
147
+ + 'read on Windows as a live SMB connection) or a protocol-relative URL (//host/...) — '
148
+ + 'which is refused and never opened. A network logo is egress at load time, not just at '
149
+ + 'render time. Point `logoPath` at a local PNG or JPEG next to the brand file.',
150
+ };
151
+ }
152
+ if (/\.svgz?$/i.test(rawLogo)) {
153
+ return {
154
+ ok: false,
155
+ message: '`logoPath` names an SVG, which is refused. SVG is markup and can carry a '
156
+ + '<script> element or event attributes, which would let the brand file defeat the '
157
+ + "report's no-script guarantee from inside. Use PNG or JPEG.",
158
+ };
159
+ }
160
+
161
+ // Containment below is a WORKFLOW-SCOPING rule (keep a brand file's assets confined to
162
+ // its own directory), not the product's egress defense — that is NETWORK_PATH_RE /
163
+ // SCHEME_RE above, which fire on the string alone and do not depend on this check or on
164
+ // how it resolves. If this containment boundary is ever relaxed (e.g. to support a logo
165
+ // shared across sibling client directories), the NETWORK_PATH_RE / SCHEME_RE checks above
166
+ // must not be relaxed with it.
167
+ const dir = path.dirname(brandPath);
168
+ const file = path.resolve(dir, rawLogo);
169
+ const rel = path.relative(dir, file);
170
+ if (escapesDir(rel)) {
171
+ return {
172
+ ok: false,
173
+ message: `\`logoPath\` must resolve to a file inside ${dir} — the directory containing `
174
+ + 'this brand file — and it does not. Keep the logo next to `brand.json` and reference '
175
+ + 'it by a plain relative name.',
176
+ };
177
+ }
178
+
179
+ // The lexical check above proves the requested NAME stays inside the directory; it does
180
+ // not prove a symlink planted there doesn't point outside it (containment compares
181
+ // strings, not the filesystem's actual graph). Re-check on the REALPATH of both sides —
182
+ // resolving both `dir` and `file` keeps this correct even when the directory prefix
183
+ // itself contains a symlink component (e.g. macOS's /var -> /private/var), since both
184
+ // sides then resolve through the same prefix and stay comparable. Needs local write
185
+ // access to the brand directory to exploit (planting the symlink), so it is a narrower
186
+ // threat than the network/traversal checks above, but containment is a security boundary
187
+ // and this is one extra pair of syscalls, not a redesign.
188
+ let realDir;
189
+ let realFile;
190
+ try {
191
+ realDir = await fsp.realpath(dir);
192
+ realFile = await fsp.realpath(file);
193
+ } catch (e) {
194
+ return { ok: false, message: `Could not read the logo at \`logoPath\`: ${e?.message || e}` };
195
+ }
196
+ if (escapesDir(path.relative(realDir, realFile))) {
197
+ return {
198
+ ok: false,
199
+ message: `\`logoPath\` resolves — through a symlink — to a file outside ${dir}, the `
200
+ + 'directory containing this brand file. Keep the logo, and anything it symlinks to, '
201
+ + 'inside that directory.',
202
+ };
203
+ }
204
+
205
+ // The size cap is enforced by STAT, before any byte is read into memory — a file inside
206
+ // the brand directory (or a symlink it targets) is not otherwise bounded, and reading an
207
+ // arbitrarily large file fully into a Buffer before checking its length defeats the whole
208
+ // point of having a cap. Keep the post-read length check below too, as a second gate
209
+ // against a TOCTOU grow-after-stat, but the stat is what actually stops the large read.
210
+ let stat;
211
+ try { stat = await fsp.stat(realFile); }
212
+ catch (e) {
213
+ return { ok: false, message: `Could not read the logo at \`logoPath\`: ${e?.message || e}` };
214
+ }
215
+ if (!stat.isFile()) {
216
+ return { ok: false, message: `\`logoPath\` at ${file} is not a regular file.` };
217
+ }
218
+ if (stat.size > MAX_LOGO_BYTES) {
219
+ return {
220
+ ok: false,
221
+ message: `The logo at \`logoPath\` is ${stat.size} bytes, over the ${MAX_LOGO_BYTES}-byte `
222
+ + 'limit — refused by its reported size before being read into memory. It is embedded '
223
+ + 'in every copy of the report, so size is too large to accept.',
224
+ };
225
+ }
226
+
227
+ let buf;
228
+ try { buf = await fsp.readFile(realFile); }
229
+ catch (e) {
230
+ return { ok: false, message: `Could not read the logo at \`logoPath\`: ${e?.message || e}` };
231
+ }
232
+ if (buf.length > MAX_LOGO_BYTES) {
233
+ return {
234
+ ok: false,
235
+ message: `The logo at \`logoPath\` is ${buf.length} bytes, over the ${MAX_LOGO_BYTES}-byte `
236
+ + 'limit — it is embedded in every copy of the report, so size is too large to accept.',
237
+ };
238
+ }
239
+ const mime = sniff(buf); // MAGIC BYTES: an extension is a claim, the bytes are the fact
240
+ if (!mime) {
241
+ return {
242
+ ok: false,
243
+ message: '`logoPath` is neither PNG nor JPEG by its own bytes (its extension is not '
244
+ + 'trusted). Use a real PNG or JPEG file.',
245
+ };
246
+ }
247
+ brand.logoDataUri = `data:${mime};base64,${buf.toString('base64')}`;
248
+ }
249
+ }
250
+
251
+ return { ok: true, brand };
252
+ }
@@ -35,6 +35,7 @@ export const CAPABILITIES = {
35
35
  proAI: { tier: 'pro', desc: 'Pro analysis prompts (deeper context than the CE prompt set).' },
36
36
  analysisAgents: { tier: 'pro', desc: 'Auth, crypto, config and service analysis agents over scan evidence. Analysis of collected evidence — NOT active exploitation probes.' },
37
37
  advancedCTEM: { tier: 'pro', desc: 'Extended scan-history retention beyond the CE window.' },
38
+ clientReporting: { tier: 'pro', desc: 'The `report` subcommand: a print-ready branded HTML report over a completed scan run, and a findings CSV using the column names of Jira\'s CSV importer. Reads a run directory; adds no network path.' },
38
39
  enhancedRedaction: { tier: 'pro', desc: 'Additional redaction patterns over the CE set for AI prompt payloads.' },
39
40
  proMCP: { tier: 'pro', desc: 'MCP tools probe_service and get_vulnerabilities.' },
40
41