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.
- package/README.md +70 -3
- package/cli.mjs +443 -7
- package/package.json +1 -1
- package/utils/brand.mjs +252 -0
- package/utils/capabilities.mjs +1 -0
- package/utils/executive_report.mjs +574 -0
- package/utils/jira_export.mjs +174 -0
- package/utils/report_finding_census.mjs +123 -0
- package/utils/report_inputs.mjs +537 -0
- package/utils/run_record.mjs +241 -0
- package/utils/scan_history.mjs +1 -1
package/utils/brand.mjs
ADDED
|
@@ -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, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
package/utils/capabilities.mjs
CHANGED
|
@@ -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
|
|