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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// utils/jira_export.mjs — the Jira-importer CSV export.
|
|
2
|
+
//
|
|
3
|
+
// CSV using the column names of Jira's CSV importer (Summary, Description, Priority, Labels,
|
|
4
|
+
// External ID); the import mapping is done in Jira and has not been verified against a live
|
|
5
|
+
// Jira instance.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ NOTHING HERE MAY CLAIM A WORKING OR VERIFIED JIRA INTEGRATION. `External ID` is stable per
|
|
8
|
+
// finding so that an importer WHICH KEYS ON IT can update rather than duplicate — that is a
|
|
9
|
+
// statement of intent, unverified against Jira. Earn-back is a Gate-3-shaped operator check:
|
|
10
|
+
// import the sample CSV into a free Jira Cloud project once and record PASS with evidence.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ The formula-injection apostrophe below is visible in the RAW file, not only in a
|
|
13
|
+
// spreadsheet: a title/detail/remediation beginning with `=`, `+`, `-` or `@` carries a
|
|
14
|
+
// prefixed `'` so the cell is inert when opened as a spreadsheet, and that same literal leading
|
|
15
|
+
// apostrophe is exactly what a consultant sees reading the CSV directly, e.g. in a text editor.
|
|
16
|
+
//
|
|
17
|
+
// Consumes Task 4's normalised model (utils/report_inputs.mjs's `loadRun()` — the same object
|
|
18
|
+
// the executive HTML report renders), specifically `model.startedAt` (never a render-time
|
|
19
|
+
// timestamp — the row is a record of what the SCAN found, not of when this file was produced)
|
|
20
|
+
// and `model.findings` (the flat array; each entry's shape is `shapeFinding()`'s: host, port,
|
|
21
|
+
// severity, title, detail, remediation, cves, kev, epss — every one of which can originate in a
|
|
22
|
+
// scanned target's banner text or a hostile `--host-file` and must be treated as untrusted).
|
|
23
|
+
//
|
|
24
|
+
// A CSV is opened in a spreadsheet or an importer, not a browser, so the risk shape here is
|
|
25
|
+
// RFC4180 structural integrity (a stray `,`/`"`/CR/LF must never let one finding's cell bleed
|
|
26
|
+
// into the next row or column) and spreadsheet FORMULA INJECTION (a cell beginning with
|
|
27
|
+
// `=`/`+`/`-`/`@` is executed as a formula by Excel/Sheets/LibreOffice the moment the file is
|
|
28
|
+
// opened) — never HTML/script injection, which does not apply to this output format.
|
|
29
|
+
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
export const JIRA_COLUMNS = ['Summary', 'Description', 'Priority', 'Labels', 'External ID'];
|
|
33
|
+
|
|
34
|
+
// Jira priorities are site-configurable — this mapping is a CLAIM, and an unstated one is a
|
|
35
|
+
// claim nobody can check. Pinned by test; do not change without updating the pinned test.
|
|
36
|
+
export const PRIORITY_BY_SEVERITY = {
|
|
37
|
+
CRITICAL: 'Highest', HIGH: 'High', MEDIUM: 'Medium', LOW: 'Low', INFO: 'Lowest',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A severity this repo's own `shapeFinding()` never emits (it always uppercases and defaults to
|
|
41
|
+
// 'INFO') should not be reachable from a well-formed model — but this module does not control
|
|
42
|
+
// its caller, so a null/undefined/unrecognised severity falls back to a stated safe default
|
|
43
|
+
// rather than writing the literal string "undefined" into a Jira Priority cell.
|
|
44
|
+
const DEFAULT_PRIORITY = 'Medium';
|
|
45
|
+
|
|
46
|
+
// ── RFC4180 quoting + formula-injection neutralisation ──────────────────────────────────────
|
|
47
|
+
//
|
|
48
|
+
// A field is wrapped in `"` (doubling any embedded `"`) when it contains a comma, a double
|
|
49
|
+
// quote, or either newline convention (`\n` alone, `\r` alone, or `\r\n`) — a LONE `\r` must be
|
|
50
|
+
// quoted too, precisely because it is not the row's own separator (rows are joined with `\n`
|
|
51
|
+
// only, below) and an unquoted one would otherwise sit mid-row indistinguishable from ordinary
|
|
52
|
+
// content only by luck.
|
|
53
|
+
//
|
|
54
|
+
// Before that: a cell whose first character is `=`, `+`, `-` or `@` is treated as a formula by
|
|
55
|
+
// Excel, Google Sheets and LibreOffice when the file is opened directly — a well-known CSV
|
|
56
|
+
// hazard, and a real one here, because a finding's title/detail/remediation is scan output from
|
|
57
|
+
// someone else's infrastructure, not text this product authored. A leading `'` forces text
|
|
58
|
+
// interpretation in all three without altering the visible value once opened.
|
|
59
|
+
function csvField(value) {
|
|
60
|
+
let str = value == null ? '' : String(value);
|
|
61
|
+
if (/^[=+\-@]/.test(str)) str = `'${str}`;
|
|
62
|
+
if (/[",\n\r]/.test(str)) str = `"${str.replace(/"/g, '""')}"`;
|
|
63
|
+
return str;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function csvRow(fields) {
|
|
67
|
+
return fields.map(csvField).join(',');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── External ID ──────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// Stable (never a counter, never Date.now(), never Math.random()) and unique within one CSV.
|
|
73
|
+
// `host + port + title` alone COLLIDES for two findings reported under one title on one port —
|
|
74
|
+
// e.g. two distinct weak-cipher findings on the same host:port — and an importer keyed on
|
|
75
|
+
// External ID would then treat the second as an UPDATE of the first, silently dropping one
|
|
76
|
+
// finding from the client's board. `detail` is folded into the key for exactly that reason.
|
|
77
|
+
//
|
|
78
|
+
// Two findings can still be identical in all four fields (a genuine duplicate, or a scan that
|
|
79
|
+
// legitimately reports the same condition twice). Those are disambiguated by their ORDINAL
|
|
80
|
+
// among equal keys, not by anything that varies between renders of the SAME model — the
|
|
81
|
+
// ordinal is a pure function of the (model-determined) order of `model.findings`, so re-running
|
|
82
|
+
// this over the same model reproduces the same ids in the same order every time.
|
|
83
|
+
function externalIds(findings) {
|
|
84
|
+
const seen = new Map();
|
|
85
|
+
return findings.map((f) => {
|
|
86
|
+
const key = JSON.stringify([keyPart(f.host), keyPart(f.port), keyPart(f.title), keyPart(f.detail)]);
|
|
87
|
+
const ordinal = seen.get(key) ?? 0;
|
|
88
|
+
seen.set(key, ordinal + 1);
|
|
89
|
+
const material = ordinal === 0 ? key : `${key} ${ordinal}`;
|
|
90
|
+
return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function keyPart(v) {
|
|
95
|
+
return v == null ? '' : String(v);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Labels ───────────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// Jira's CSV importer takes multiple label VALUES in one cell space-separated (there is only
|
|
101
|
+
// one `Labels` column in JIRA_COLUMNS, never one column per label) — so each token here is
|
|
102
|
+
// sanitised to contain no whitespace of its own, or hostile data (e.g. a CVE-shaped string that
|
|
103
|
+
// happens to contain a space) would silently fragment into more Jira labels than the scan
|
|
104
|
+
// actually reported.
|
|
105
|
+
function labelToken(raw) {
|
|
106
|
+
return String(raw).trim().toLowerCase().replace(/\s+/g, '-');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildLabels(f) {
|
|
110
|
+
const labels = ['nsauditor'];
|
|
111
|
+
const severity = keyPart(f.severity);
|
|
112
|
+
if (severity) labels.push(labelToken(`severity-${severity}`));
|
|
113
|
+
if (f.kev) labels.push('kev');
|
|
114
|
+
for (const cve of Array.isArray(f.cves) ? f.cves : []) {
|
|
115
|
+
const token = labelToken(cve);
|
|
116
|
+
if (token) labels.push(token);
|
|
117
|
+
}
|
|
118
|
+
return labels.join(' ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Description ──────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
//
|
|
123
|
+
// Carries the SCAN's own timestamp (`model.startedAt`) and the finding's host — never a render
|
|
124
|
+
// date, so re-running this exporter next week over an old run does not make the ticket look
|
|
125
|
+
// like it was just found.
|
|
126
|
+
//
|
|
127
|
+
// Sections are joined with ` | `, never `\n` — a raw newline is what `csvField` quotes FOR, not
|
|
128
|
+
// something this module should manufacture on its own out of otherwise single-line content. The
|
|
129
|
+
// only way this field carries an embedded newline is a genuine one already present in the
|
|
130
|
+
// finding's own `detail`/`remediation` (untrusted scan output), preserved as-is and quoted per
|
|
131
|
+
// RFC4180 — never introduced by this module's own formatting.
|
|
132
|
+
function buildDescription(model, f) {
|
|
133
|
+
const where = f.port != null ? `${keyPart(f.host)}:${f.port}` : keyPart(f.host);
|
|
134
|
+
const parts = [`Host: ${where}`, `Scan started: ${keyPart(model?.startedAt)}`];
|
|
135
|
+
if (f.detail) parts.push(String(f.detail));
|
|
136
|
+
if (f.remediation) parts.push(`Remediation: ${f.remediation}`);
|
|
137
|
+
const cves = Array.isArray(f.cves) ? f.cves.filter(Boolean) : [];
|
|
138
|
+
if (cves.length) parts.push(`CVEs: ${cves.join(', ')}`);
|
|
139
|
+
if (f.kev) parts.push('Listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.');
|
|
140
|
+
if (typeof f.epss === 'number' && Number.isFinite(f.epss)) parts.push(`EPSS: ${f.epss}`);
|
|
141
|
+
return parts.join(' | ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function priorityFor(severity) {
|
|
145
|
+
const key = keyPart(severity).toUpperCase();
|
|
146
|
+
return PRIORITY_BY_SEVERITY[key] ?? DEFAULT_PRIORITY;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render Task 4's normalised run model as a Jira-CSV-importer-shaped CSV.
|
|
151
|
+
* @param {object} model - the object `utils/report_inputs.mjs`'s `loadRun()` resolves to.
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function renderJiraCsv(model) {
|
|
155
|
+
// A Jira issue is a WORK ITEM, and a passing check is not work. Before this filter a
|
|
156
|
+
// real AWS run emitted all 18 PASS records as issues — 17 of them carrying substrate
|
|
157
|
+
// prose a consultant would have had to close by hand, and one contentless clean-user
|
|
158
|
+
// record that imported as an issue with no Summary at all.
|
|
159
|
+
//
|
|
160
|
+
// ⚠️ THE FILTER IS DELIBERATELY NARROW — `pass` ONLY. Every other tier, INFO included,
|
|
161
|
+
// stays: INFO carries the evidence gaps and the scope boundaries, which are exactly the
|
|
162
|
+
// material a reader needs to tell an incomplete scan from a clean one.
|
|
163
|
+
const all = Array.isArray(model?.findings) ? model.findings : [];
|
|
164
|
+
const findings = all.filter((f) => String(f?.severity ?? '').toUpperCase() !== 'PASS');
|
|
165
|
+
const ids = externalIds(findings);
|
|
166
|
+
const rows = findings.map((f, i) => csvRow([
|
|
167
|
+
f.title ?? '',
|
|
168
|
+
buildDescription(model, f),
|
|
169
|
+
priorityFor(f.severity),
|
|
170
|
+
buildLabels(f),
|
|
171
|
+
ids[i],
|
|
172
|
+
]));
|
|
173
|
+
return [JIRA_COLUMNS.join(','), ...rows].join('\n') + '\n';
|
|
174
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// utils/report_finding_census.mjs
|
|
2
|
+
//
|
|
3
|
+
// WHICH CONTAINERS IN A SCAN RECORD CAN HOLD A FINDING, AND DOES THE REPORT READ THEM?
|
|
4
|
+
//
|
|
5
|
+
// Built after Gate 3-B drove the `report` subcommand over a real 192.168.1.1 run and the
|
|
6
|
+
// client deliverable said, over sixteen known CVEs:
|
|
7
|
+
//
|
|
8
|
+
// "No findings were recorded in this run."
|
|
9
|
+
// CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0 · INFO 0 · PASS 0 · OTHER 0
|
|
10
|
+
//
|
|
11
|
+
// `shapeHost` read exactly one container — `result.findings[]` as an ARRAY — and on a
|
|
12
|
+
// network host that container is EMPTY. Measured on that run: 47 severity-bearing rows
|
|
13
|
+
// live in four other places, and none of them was opened.
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ WHY THIS IS A CENSUS AND NOT FIVE MORE READERS. The defect was never "we forgot the
|
|
16
|
+
// queue". It was that nothing in the product could answer "is there a finding we are not
|
|
17
|
+
// rendering?" — so each door had to be found by a human reading raw JSON. A census keyed
|
|
18
|
+
// on CONTAINERS answers that question by construction: every container carrying a
|
|
19
|
+
// severity-bearing object is either READ or explicitly ALLOWLISTED with a written reason,
|
|
20
|
+
// and one that is NEITHER fails loudly. Incompleteness costs NOISE, never SILENCE — the
|
|
21
|
+
// inverse of the shape that produced the false clean.
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ AND THE FIXTURE LESSON THAT MADE IT NECESSARY: the fleet fixture written one round
|
|
24
|
+
// earlier proves `shapeFinding` over every finding SHAPE, and it could not see this,
|
|
25
|
+
// because the network path's failure is a SOURCE the consumer never opens. A fixture
|
|
26
|
+
// drawn from a producer's field names cannot catch a producer whose output lives in a
|
|
27
|
+
// different artifact.
|
|
28
|
+
|
|
29
|
+
/** Containers the report READS. Adding a reader means adding its key here. */
|
|
30
|
+
export const READ_CONTAINERS = Object.freeze([
|
|
31
|
+
'result.findings[]', // array-shaped plugin findings (the cloud shape)
|
|
32
|
+
'result.findings.<category>[]', // dict-of-categories (060 DNS Security Auditor)
|
|
33
|
+
'result.zeroTrust.<dim>.findings[]', // 1023 Zero Trust Assessment
|
|
34
|
+
'result.portResults[].issues[]', // 040 TLS Certificate & Cipher Auditor
|
|
35
|
+
'findingQueue[]', // the EE finding queue (intelligence_engine, crypto_agent)
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Containers that carry a `severity` but are NOT findings. Each needs a written reason —
|
|
40
|
+
* an allowlist entry with no reason is how a real door gets closed by accident.
|
|
41
|
+
*/
|
|
42
|
+
export const ALLOWLISTED_CONTAINERS = Object.freeze({
|
|
43
|
+
'result.portResults[]':
|
|
44
|
+
'A per-port ROLL-UP, not a finding: its severity summarises the port\'s own '
|
|
45
|
+
+ '`issues[]`, which ARE read. Rendering both would double-count every TLS issue.',
|
|
46
|
+
'result.summary.cisAlarmCoverage.uncovered[]':
|
|
47
|
+
'A 1:1 SUMMARY MIRROR of 1040\'s own alarm-coverage findings, not a second source. '
|
|
48
|
+
+ 'Adjudicated on the 0.44.0 cloud run: 13 uncovered classes, 13 "alarm missing" '
|
|
49
|
+
+ 'findings, and the set difference of their cis-N.N ids is EMPTY. If that ever stops '
|
|
50
|
+
+ 'holding the mirror has broken and this entry is wrong — re-derive, do not re-assert.',
|
|
51
|
+
'result.findings[].policyAnalyses[].issues[]':
|
|
52
|
+
'Per-statement EVIDENCE nested inside a finding that IS rendered — e.g. "Full admin '
|
|
53
|
+
+ 'grant: Action \'*\' in policy X" backing a parent whose own issues[] already say '
|
|
54
|
+
+ '"SHADOW ADMIN: User has full wildcard (*) permissions". Not a missed finding. '
|
|
55
|
+
+ 'RESIDUAL, stated rather than hidden: the per-statement detail is not surfaced in the '
|
|
56
|
+
+ 'report at all, which is a DEPTH choice nobody has ruled on, not a coverage gap.',
|
|
57
|
+
'result.data[]':
|
|
58
|
+
'NOT a findings container, and the report must never read it. On a CLOUD host it '
|
|
59
|
+
+ 'MIRRORS result.findings (verified 0.44.0: 1150 16/16, 1170 9/9). On a NETWORK host '
|
|
60
|
+
+ 'it is PROBE TELEMETRY — measured on the 192.168.1.1 run, findings[]=0 while '
|
|
61
|
+
+ 'data[]=85, 84 of them {probe_protocol, probe_port, probe_info} ("Connect refused '
|
|
62
|
+
+ '(ECONNREFUSED)", "No UDP response"). ⚠️ A `findings ?? data` fallback was written '
|
|
63
|
+
+ 'here and REVERTED: it put scan telemetry into a client deliverable, hidden because '
|
|
64
|
+
+ 'the telemetry rows collided on one content hash and collapsed to two. '
|
|
65
|
+
+ 'cloud_finding_summary\'s findingsOf may use that precedence — it only ever sees '
|
|
66
|
+
+ 'cloud producers; this consumer sees both paths.',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const QUEUE_CONTAINER = 'findingQueue[]';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify where a severity-bearing object lives. Path is the dotted walk from `result`.
|
|
73
|
+
* @returns {string} a container key
|
|
74
|
+
*/
|
|
75
|
+
function containerOf(path) {
|
|
76
|
+
if (/^\.findings\[\d+\]$/.test(path)) return 'result.findings[]';
|
|
77
|
+
if (/^\.findings\.[A-Za-z0-9_]+\[\d+\]$/.test(path)) return 'result.findings.<category>[]';
|
|
78
|
+
if (/zeroTrust\..*\.findings\[\d+\]$/.test(path)) return 'result.zeroTrust.<dim>.findings[]';
|
|
79
|
+
if (/^\.portResults\[\d+\]\.issues\[\d+\]$/.test(path)) return 'result.portResults[].issues[]';
|
|
80
|
+
if (/^\.portResults\[\d+\]$/.test(path)) return 'result.portResults[]';
|
|
81
|
+
if (/^\.summary\.cisAlarmCoverage\.uncovered\[\d+\]$/.test(path)) return 'result.summary.cisAlarmCoverage.uncovered[]';
|
|
82
|
+
if (/^\.findings\[\d+\]\.policyAnalyses\[\d+\]\.issues\[\d+\]$/.test(path)) return 'result.findings[].policyAnalyses[].issues[]';
|
|
83
|
+
if (/^\.data\[\d+\]$/.test(path)) return 'result.data[]';
|
|
84
|
+
// An UNRECOGNISED container is reported under its own generalised path rather than
|
|
85
|
+
// bucketed into "other" — a census that collapses the unknown into one bucket tells you
|
|
86
|
+
// that something is missing without telling you what.
|
|
87
|
+
return `UNCLASSIFIED:${path.replace(/\[\d+\]/g, '[]')}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function* severityObjects(node, path = '') {
|
|
91
|
+
if (Array.isArray(node)) {
|
|
92
|
+
for (let i = 0; i < node.length; i += 1) yield* severityObjects(node[i], `${path}[${i}]`);
|
|
93
|
+
} else if (node && typeof node === 'object') {
|
|
94
|
+
if (typeof node.severity === 'string') yield { path, obj: node };
|
|
95
|
+
for (const [k, v] of Object.entries(node)) yield* severityObjects(v, `${path}.${k}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Census one scan record.
|
|
101
|
+
* @param {object} raw the parsed scan_conclusion_raw.json
|
|
102
|
+
* @param {Array} queue the host's finding queue (scan_finding_queue.json, or the
|
|
103
|
+
* eeEnrichment fallback) — passed in because it is a SIBLING
|
|
104
|
+
* artifact, not part of `raw`, which is the whole reason it was missed.
|
|
105
|
+
* @returns {{byContainer: Object<string, number>, unread: Object<string, number>, total: number}}
|
|
106
|
+
*/
|
|
107
|
+
export function censusFindingContainers(raw, queue = []) {
|
|
108
|
+
const byContainer = {};
|
|
109
|
+
const bump = (k) => { byContainer[k] = (byContainer[k] || 0) + 1; };
|
|
110
|
+
|
|
111
|
+
for (const e of (Array.isArray(raw?.results) ? raw.results : [])) {
|
|
112
|
+
for (const { path } of severityObjects(e?.result ?? {}, '')) bump(containerOf(path));
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(queue) && queue.length) byContainer[QUEUE_CONTAINER] = queue.length;
|
|
115
|
+
|
|
116
|
+
const unread = {};
|
|
117
|
+
for (const [k, n] of Object.entries(byContainer)) {
|
|
118
|
+
if (READ_CONTAINERS.includes(k)) continue;
|
|
119
|
+
if (Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) continue;
|
|
120
|
+
unread[k] = n;
|
|
121
|
+
}
|
|
122
|
+
return { byContainer, unread, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
|
|
123
|
+
}
|