nsauditor-ai 0.2.53 → 0.2.55
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 +176 -565
- package/cli.mjs +271 -37
- package/mcp_server.mjs +27 -16
- package/package.json +2 -2
- package/plugin_manager.mjs +105 -13
- package/plugins/040_tls_cert_auditor.mjs +48 -0
- package/plugins/mcp_scanner.mjs +12 -0
- package/plugins/port_scanner.mjs +27 -23
- package/plugins/upnp_scanner.mjs +88 -9
- package/utils/capabilities.mjs +10 -8
- package/utils/cloud_finding_summary.mjs +1 -1
- package/utils/cloud_providers.mjs +90 -0
- package/utils/delta_reporter.mjs +8 -1
- package/utils/executive_report.mjs +100 -1
- package/utils/finding_identity.mjs +127 -0
- package/utils/host_iterator.mjs +8 -5
- package/utils/report_inputs.mjs +273 -17
- package/utils/run_chain.mjs +296 -0
- package/utils/run_record.mjs +103 -3
- package/utils/scan_delta.mjs +1122 -0
- package/utils/scan_delta_view.mjs +218 -0
- package/utils/scan_history.mjs +92 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// THE CLOUD-PROVIDER VOCABULARY, IN ONE PLACE, BECAUSE IT HAS TWO READERS IN TWO REPOSITORIES.
|
|
2
|
+
//
|
|
3
|
+
// A cloud scan addresses its provider as the HOST — `nsauditor-ai scan --host aws` writes a run
|
|
4
|
+
// directory named `aws_<timestamp>` and a run record whose `hostsWritten[].host` is the literal
|
|
5
|
+
// string `aws`. That makes the provider name a vocabulary, and a vocabulary with two copies is a
|
|
6
|
+
// vocabulary that drifts.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ IT LIVED IN `scan_delta.mjs` AS A PRIVATE CONST UNTIL EE NEEDED IT TOO, and the obvious
|
|
9
|
+
// alternative was for Enterprise's CPE mapper to import the delta engine for one literal — a
|
|
10
|
+
// PRODUCER importing a CONSUMER, to learn a fact that belongs to neither. The other alternative
|
|
11
|
+
// was a second list in EE, which is the shape this file exists to prevent.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ ONE LIST, NOT TWO. The host set is DERIVED from the unit map's keys rather than written
|
|
14
|
+
// beside it, so there is no second place to forget. Adding a provider means adding its coverage
|
|
15
|
+
// unit, and the host set follows.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The coverage unit each provider resolves. A provider scan's scope is recorded in these terms,
|
|
19
|
+
* and the cross-run delta refuses to call a finding fixed or new when its unit was outside the
|
|
20
|
+
* other run's recorded scope.
|
|
21
|
+
*/
|
|
22
|
+
export const PROVIDER_SCOPE_UNIT = Object.freeze({
|
|
23
|
+
aws: 'region',
|
|
24
|
+
azure: 'subscription',
|
|
25
|
+
gcp: 'project',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The hosts that ARE a cloud provider rather than a network target. Derived from the unit map.
|
|
30
|
+
*
|
|
31
|
+
* Used to answer "is this scan addressing an estate or an address?" — a question with different
|
|
32
|
+
* right answers for the same code. Enterprise's CPE mapper asks it because a cloud scan produces
|
|
33
|
+
* no services BY CONSTRUCTION (cloud envelopes carry no port-bearing rows), so on a cloud host an
|
|
34
|
+
* empty service set is not evidence of anything and an upstream that failed there was never an
|
|
35
|
+
* upstream of the mapper at all.
|
|
36
|
+
*/
|
|
37
|
+
export const CLOUD_PROVIDER_HOSTS = Object.freeze(Object.keys(PROVIDER_SCOPE_UNIT));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is this host a cloud provider?
|
|
41
|
+
*
|
|
42
|
+
* Case- and whitespace-insensitive because it is applied to a value that reaches the record from
|
|
43
|
+
* a command line. Anything that is not a declared provider is a network target — the safe
|
|
44
|
+
* direction: a misspelling reads as a network host, where the mapper's questions all still apply.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} host
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function isCloudProviderHost(host) {
|
|
50
|
+
return typeof host === 'string'
|
|
51
|
+
&& Object.hasOwn(PROVIDER_SCOPE_UNIT, host.trim().toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* THE HOST A RECORD, A LOADER AND A COMPARISON AGREE ON.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ `--host AWS` IS ACCEPTED, AND UNTIL THIS THE RECORD KEPT IT AS TYPED. The CLI lowercased the
|
|
58
|
+
* sentinel only for its own address check; `parseHostArg` returned `AWS`, the run record carried
|
|
59
|
+
* `AWS`, and the cross-run delta compares hosts by string — so a baseline scanned as `AWS` beside a
|
|
60
|
+
* current scanned as `aws` read every finding as host-not-scanned. Folded at PARSE, so every record
|
|
61
|
+
* from now on carries the provider's own spelling, and at every READ — the loader and the delta —
|
|
62
|
+
* because records already on disk still say `AWS`.
|
|
63
|
+
*
|
|
64
|
+
* ONLY the three provider names are folded, and only on an exact case-insensitive match. A network
|
|
65
|
+
* host is RECORDED exactly as given — whether two runs saw the same host is `hostKey`'s question, not
|
|
66
|
+
* this one's; a misspelled provider is a network host (the safe direction, as for `isCloudProviderHost`).
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} host
|
|
69
|
+
* @returns {unknown} the provider's own spelling for a provider host; the value unchanged otherwise
|
|
70
|
+
*/
|
|
71
|
+
export function canonicalHost(host) {
|
|
72
|
+
return isCloudProviderHost(host) ? host.trim().toLowerCase() : host;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* THE ONE ANSWER TO "IS THIS THE SAME HOST?" — trimmed and lower-cased, for EVERY host.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ TWO CONSUMERS ANSWERED IT DIFFERENTLY. Enterprise's MTTR history folded every host's case; this
|
|
79
|
+
* repository's delta compared hosts as exact strings, so a host scanned as `MyHost.local` and later as
|
|
80
|
+
* `myhost.local` read every finding host-not-scanned in one and paired in the other. DNS names are
|
|
81
|
+
* case-insensitive (RFC 4343), and a provider name is a fixed word, so the key folds both. It is a
|
|
82
|
+
* COMPARISON key only: a record keeps a network host as typed (only the provider names fold at parse,
|
|
83
|
+
* `canonicalHost`), and anything shown to a reader uses the recorded spelling.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} host
|
|
86
|
+
* @returns {string} '' for a non-string
|
|
87
|
+
*/
|
|
88
|
+
export function hostKey(host) {
|
|
89
|
+
return typeof host === 'string' ? host.trim().toLowerCase() : '';
|
|
90
|
+
}
|
package/utils/delta_reporter.mjs
CHANGED
|
@@ -71,7 +71,13 @@ export function formatDeltaSummary(deltaReport) {
|
|
|
71
71
|
let anyChange = false;
|
|
72
72
|
if (deltaReport.hostDiffs) {
|
|
73
73
|
for (const diff of deltaReport.hostDiffs.values()) {
|
|
74
|
-
|
|
74
|
+
// ⚠️ `findingsNotComparable` COUNTS AS A CHANGE (board C10). `newFindings` is `null` when
|
|
75
|
+
// the two scans counted findings on a different basis, and null is falsy — so without
|
|
76
|
+
// this the diff drops out of both gates and the operator hears NOTHING, which reads as
|
|
77
|
+
// "no change since last scan". That is the false clean this item is about, one layer out.
|
|
78
|
+
// A comparison we cannot make is news: it is what tells an operator to rescan.
|
|
79
|
+
if (diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length
|
|
80
|
+
|| diff.newFindings || diff.findingsNotComparable) {
|
|
75
81
|
anyChange = true;
|
|
76
82
|
break;
|
|
77
83
|
}
|
|
@@ -103,6 +109,7 @@ export function hasSignificantChanges(deltaReport) {
|
|
|
103
109
|
if (diff.removedServices?.length > 0) return true;
|
|
104
110
|
if (diff.changedServices?.length > 0) return true;
|
|
105
111
|
if (diff.newFindings && diff.newFindings !== 0) return true;
|
|
112
|
+
if (diff.findingsNotComparable) return true; // see the note in formatDeltaSummary
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
// vocabulary, even where the producer (report_inputs.mjs) is expected to constrain it.
|
|
28
28
|
|
|
29
29
|
import { escapeHtml } from './brand.mjs';
|
|
30
|
+
// The two evaluability SENTINELS, imported so the per-row basis is keyed on identity rather
|
|
31
|
+
// than on prose. `scan_delta.mjs` imports nothing from here, so there is no cycle.
|
|
32
|
+
import { SCOPE_NOT_EVALUATED, FRAMEWORK_MOVEMENT_NOT_EVALUATED } from './scan_delta.mjs';
|
|
30
33
|
|
|
31
34
|
/* ------------------------------------------------------------------------------------------
|
|
32
35
|
* egressViolations — deny-by-default. Two levels: every TAG, then EVERY attribute of it
|
|
@@ -605,7 +608,102 @@ function describeBrand(v) {
|
|
|
605
608
|
// left to a reading — `tests/executive_report_brand_absent.test.mjs` pins this module's export
|
|
606
609
|
// surface, so exporting either helper fails the build rather than silently opening an
|
|
607
610
|
// unguarded path.
|
|
608
|
-
|
|
611
|
+
|
|
612
|
+
// ── The cross-run delta, rendered for the CLIENT rather than the operator. ───────────────────
|
|
613
|
+
//
|
|
614
|
+
// ⚠️ A `resolved` ROW HERE IS AN ASSERTION OF REMEDIATION TO THAT CLIENT'S AUDITOR, so the basis
|
|
615
|
+
// travels PER ROW and never as a footnote. A footnote is precisely the shape a compression pass
|
|
616
|
+
// keeps while emptying it of force, and this lane has already watched a bare COUNT survive a
|
|
617
|
+
// mutation battery on the easier stdout surface. The reader of this document does not know what
|
|
618
|
+
// the tool does; the operator did.
|
|
619
|
+
// ⚠️ DERIVED FROM THE LIMITS IN FORCE, NEVER WRITTEN AS A CONSTANT. This returned a fixed
|
|
620
|
+
// sentence — "host, plugin, scope and framework enumeration present in both runs" — beside every
|
|
621
|
+
// resolved / new / changed row, on a page whose own limits block said framework movement was NOT
|
|
622
|
+
// EVALUATED and (before scope was threaded) that scope never was either. The page contradicted
|
|
623
|
+
// itself, and of the two the ROW is the one that has to be true: the limit is what a compression
|
|
624
|
+
// pass deletes, and the row is what a reader sees beside the word `resolved`.
|
|
625
|
+
//
|
|
626
|
+
// ⚠️ KEYED ON THE EXPORTED SENTINELS, NEVER ON PROSE. Matching the limit text would rot the first
|
|
627
|
+
// time somebody copy-edits a sentence, and it would rot SILENTLY in the direction that overstates.
|
|
628
|
+
function deltaBasis(delta) {
|
|
629
|
+
const limits = delta.limits ?? [];
|
|
630
|
+
const scopeEvaluated = !limits.includes(SCOPE_NOT_EVALUATED);
|
|
631
|
+
const fwEvaluated = !limits.includes(FRAMEWORK_MOVEMENT_NOT_EVALUATED);
|
|
632
|
+
const legs = ['host', 'plugin', scopeEvaluated ? 'scope' : null].filter(Boolean).join(', ');
|
|
633
|
+
const fw = fwEvaluated ? 'present in both runs' : 'not evaluated';
|
|
634
|
+
// BOTH sides' integrity: the run being REPORTED is as alterable as the one compared against.
|
|
635
|
+
const integrity = `baseline ${delta.baselineIntegrity} · current ${delta.currentIntegrity ?? 'not recorded'}`;
|
|
636
|
+
return `comparable: ${legs} present in both runs; framework enumeration: ${fw}; ${integrity}`;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function deltaRow(bucket, f, basis) {
|
|
640
|
+
// ⚠️ THE REGION RIDES THE RESOURCE CELL (board E1(b)). Until E1 the region reached this table
|
|
641
|
+
// only as a ` [<region>]` suffix that EE's region stamper had written INTO `resource` — which
|
|
642
|
+
// is also why the delta keyed identity on a decorated value. E1 removes the decoration and
|
|
643
|
+
// makes `region` a field, and the delta's key separates the regions correctly; but a reader
|
|
644
|
+
// sees this table, and six `backup:account` rows from six regions would render IDENTICALLY.
|
|
645
|
+
// Six indistinguishable rows in a change table is worse than a collapse — it reads as a
|
|
646
|
+
// rendering fault and a reader can act on none of them.
|
|
647
|
+
//
|
|
648
|
+
// It is appended to the cell rather than given a column of its own because most producers
|
|
649
|
+
// carry no region at all: a sixth column would be em-dashes on every network finding, which
|
|
650
|
+
// trains a reader to ignore the cell that matters. Absent region → the cell is exactly what
|
|
651
|
+
// it was before.
|
|
652
|
+
const resourceCell = f.region
|
|
653
|
+
? `${escapeHtml(f.resource ?? '—')} <span class="delta-region">[${escapeHtml(f.region)}]</span>`
|
|
654
|
+
: escapeHtml(f.resource ?? '—');
|
|
655
|
+
return `<tr class="delta-${bucket}"><td>${escapeHtml(bucket)}</td>`
|
|
656
|
+
+ `<td>${escapeHtml(f.title ?? '')}</td>`
|
|
657
|
+
+ `<td>${resourceCell}</td>`
|
|
658
|
+
+ `<td>${escapeHtml(String(f.severity ?? '—'))}</td>`
|
|
659
|
+
+ `<td>${escapeHtml(basis)}</td></tr>`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function renderDelta(delta) {
|
|
663
|
+
if (!delta) return ''; // no `--since` was asked for: no section, never an empty or invented one.
|
|
664
|
+
|
|
665
|
+
if (!delta.comparable) {
|
|
666
|
+
// ⚠️ A REFUSED COMPARISON NAMES NO FINDING. Listing them beside a refusal invites the reader
|
|
667
|
+
// to draw the verdict the refusal exists to withhold.
|
|
668
|
+
return `<section id="delta">
|
|
669
|
+
<h2>Since Last Scan</h2>
|
|
670
|
+
<p class="delta-refused"><strong>No comparison was made.</strong> ${escapeHtml(delta.refusal.reason)} —
|
|
671
|
+
${escapeHtml(delta.refusal.detail)}</p>
|
|
672
|
+
<p>Nothing in this report is stated as fixed, new or unchanged relative to an earlier scan.</p>
|
|
673
|
+
</section>`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const rows = [
|
|
677
|
+
...delta.resolved.map((f) => deltaRow('resolved', f, deltaBasis(delta))),
|
|
678
|
+
...delta.newFindings.map((f) => deltaRow('new', f, deltaBasis(delta))),
|
|
679
|
+
...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta)}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
|
|
680
|
+
// The reason rides the row, not a legend.
|
|
681
|
+
// ⚠️ THE CODE IS PRINTED VERBATIM, AND THAT IS THE DESIGN. A render-time label map was tried
|
|
682
|
+
// here and reverted: it moved this seam and left the terminal's, so one outcome carried two
|
|
683
|
+
// names. The noun that made a code wrong in front of a human was removed from the CODE
|
|
684
|
+
// instead, which fixes every seam at once. See `NOT_COMPARABLE_REASONS` in scan_delta.mjs.
|
|
685
|
+
...delta.notComparable.map((f) => deltaRow('not-comparable', f, `${f.reason}: ${f.detail}`)),
|
|
686
|
+
].join('\n');
|
|
687
|
+
|
|
688
|
+
const counts = `${delta.newFindings.length} new · ${delta.resolved.length} resolved · `
|
|
689
|
+
+ `${delta.changed.length} changed · ${delta.notComparable.length} not comparable · `
|
|
690
|
+
+ `${delta.unchanged.length} unchanged`;
|
|
691
|
+
|
|
692
|
+
return `<section id="delta">
|
|
693
|
+
<h2>Since Last Scan</h2>
|
|
694
|
+
<p class="delta-counts">${escapeHtml(counts)}</p>
|
|
695
|
+
${delta.notComparable.length ? `<p class="delta-nc-note">${escapeHtml(String(delta.notComparable.length))} finding(s) could NOT be compared between the two scans. They are listed below with the reason. A finding that could not be compared is <strong>not</strong> a finding that was fixed.</p>` : ''}
|
|
696
|
+
<table class="delta"><thead><tr><th>Change</th><th>Finding</th><th>Resource</th><th>Severity</th><th>Basis</th></tr></thead>
|
|
697
|
+
<tbody>
|
|
698
|
+
${rows}
|
|
699
|
+
</tbody></table>
|
|
700
|
+
<div class="delta-limits">
|
|
701
|
+
${delta.limits.map((l) => `<p class="limit">${escapeHtml(l)}</p>`).join('\n')}
|
|
702
|
+
</div>
|
|
703
|
+
</section>`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function renderExecutiveReport(model, brand, { renderedAt = new Date(), delta = null } = {}) {
|
|
609
707
|
if (brand === null || typeof brand !== 'object' || Array.isArray(brand)) {
|
|
610
708
|
throw new TypeError(
|
|
611
709
|
'renderExecutiveReport: `brand` must be an object — pass the `brand` field of '
|
|
@@ -626,6 +724,7 @@ export function renderExecutiveReport(model, brand, { renderedAt = new Date() }
|
|
|
626
724
|
<body>
|
|
627
725
|
${renderCover(model, brand, renderedAt)}
|
|
628
726
|
${renderTopRisks(model.findings)}
|
|
727
|
+
${renderDelta(delta)}
|
|
629
728
|
<section id="chart">
|
|
630
729
|
<h2>Severity Distribution</h2>
|
|
631
730
|
${renderChart(model.findings)}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE canonical `resource`, shared by every channel that identifies a finding across runs.
|
|
3
|
+
*
|
|
4
|
+
* Two such channels ship, and before E1 they derived this noun independently:
|
|
5
|
+
* · CE's cross-run delta (`report --since`) — `utils/scan_delta.mjs`'s `keyOf`
|
|
6
|
+
* · EE's SLA/MTTR tracker (`--compliance-history`) — `utils/mttr_engine.mjs`'s fingerprint
|
|
7
|
+
* Two channels deriving the same noun two ways is the C10 defect one noun down, and its cost is
|
|
8
|
+
* concrete: an assessor can be shown one report where a finding is "resolved" and another where
|
|
9
|
+
* it is "unchanged", for the same finding on the same estate.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ THIS CANONICALISES THE NOUN, NOT THE KEY. Each channel keeps its OWN composition, and that
|
|
12
|
+
* is deliberate rather than an omission — they ask different questions. The delta asks *what
|
|
13
|
+
* changed* and needs `contentDigest` in its key; the tracker asks *how long has this been open*
|
|
14
|
+
* and must NOT reset a remediation clock because some prose it does not key on was reworded.
|
|
15
|
+
* Sharing the whole key would import each channel's sensitivities into the other.
|
|
16
|
+
*
|
|
17
|
+
* WHY A CANONICALISER EXISTS AT ALL — the decoration it removes was manufactured downstream of
|
|
18
|
+
* every producer. `utils/aws_region_scan.mjs::_stampRegion` (EE) rewrote `resource` on every
|
|
19
|
+
* finding routed through `forEachRegion`: a finding WITHOUT a resource got the region itself as
|
|
20
|
+
* its object identity, and a finding WITH one got ` [<region>]` appended. E1 stops both, but the
|
|
21
|
+
* published CE 0.2.54 already wrote baselines carrying the decorated values — and
|
|
22
|
+
* `RUN_RECORD_SCHEMA` is 1 on both sides, so those baselines are COMPARABLE by declaration. This
|
|
23
|
+
* function is what makes that declaration true for every producer that already named its object.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The shape of a cloud region token. Two to four leading letters, one to three dash-separated
|
|
28
|
+
* alpha groups, a trailing number: `us-east-1` · `us-gov-west-1` · `ap-southeast-2` ·
|
|
29
|
+
* `cn-north-1` · `eusc-de-east-1` (European Sovereign Cloud).
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ IT IS ONLY EVER USED ANCHORED, and that is the load-bearing part. Applied as a SCAN over a
|
|
32
|
+
* resource string it matches **562 of the 1786** stored violation resources in the 1.1.0
|
|
33
|
+
* evidence tree — `s3:bucket:s3-violator-bucket-522412052794` contains `ator-bucket-52` — and
|
|
34
|
+
* every one of those would attribute a fabricated region to a finding.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ AND ITS FIRST DRAFT WAS DERIVED FROM A SINGLE-REGION CORPUS, so it missed `eusc-de-east-1`
|
|
37
|
+
* and left that suffix decorating identity forever. A shape derived from a corpus that carries
|
|
38
|
+
* one value is a shape nobody has tested.
|
|
39
|
+
*/
|
|
40
|
+
// ⚠️ EXPORTED so a consumer can assert against the SHAPE rather than against a region somebody
|
|
41
|
+
// typed. Enterprise's MTTR gap-text canonicaliser has to preserve a region token while cutting
|
|
42
|
+
// the volatile count beside it, and a fixture written from one remembered region is a fixture
|
|
43
|
+
// that tests the memory — this comment's own history says so: the first draft of this shape was
|
|
44
|
+
// derived from a single-region corpus and missed `eusc-de-east-1`.
|
|
45
|
+
export const REGION_TOKEN = '[a-z]{2,4}(?:-[a-z]+){1,3}-\\d{1,2}';
|
|
46
|
+
const TRAILING_REGION_DECORATION = new RegExp(`^(.*?) \\[(${REGION_TOKEN})\\]$`);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The region a finding belongs to — the STAMPED FIELD, and deliberately nothing cleverer.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ TWO RICHER VERSIONS OF THIS FUNCTION WERE SPECIFIED AND BOTH WERE WITHDRAWN BY
|
|
52
|
+
* MEASUREMENT, which is why this one is three lines. The idea was to recover a region for a
|
|
53
|
+
* STORED violation — from the ` [<region>]` decoration, or by parsing an ARN's region field —
|
|
54
|
+
* so that `region` could join the MTTR composite. Measured over the whole evidence tree, all
|
|
55
|
+
* 69,792 stored violations:
|
|
56
|
+
* · carrying a `region` field ............................ 0
|
|
57
|
+
* · whose resource EMBEDS a region token ................. 0 → the ARN parse has no occupant
|
|
58
|
+
* · carrying a ` [<region>]` suffix ...................... 2,873
|
|
59
|
+
* · whose resource IS a bare region ...................... 5,950 (the basis-bumped producers)
|
|
60
|
+
* · with no region recoverable by any means ............. 60,959
|
|
61
|
+
* Sixty thousand priors can yield no region at all — mostly producers with an empty or
|
|
62
|
+
* name-only resource. So ANY composite carrying region on the current side while the prior
|
|
63
|
+
* yields null resets those lifecycles at the upgrade boundary: the repair would have introduced
|
|
64
|
+
* the defect it was written to prevent, on a larger population than the one it fixed.
|
|
65
|
+
*
|
|
66
|
+
* Region enters MTTR identity by COMPOSITION NEGOTIATION instead (see `mttr_engine.mjs`): the
|
|
67
|
+
* composition is chosen by what the PRIOR scan stores, so the two sides are always commensurable
|
|
68
|
+
* by construction rather than by rescuing one of them. Here, region is simply the field — which
|
|
69
|
+
* is all the delta ever needed, because both sides of a delta are loader-shaped runs that carry
|
|
70
|
+
* it.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} v — a finding or a stored violation.
|
|
73
|
+
* @returns {string|null}
|
|
74
|
+
*/
|
|
75
|
+
export function regionOf(v) {
|
|
76
|
+
return typeof v?.region === 'string' && v.region.length > 0 ? v.region : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {unknown} resource — the raw `resource` as the producer (or the stamper) left it.
|
|
81
|
+
* @param {unknown} region — the finding's OWN region, from its own field. Never a default:
|
|
82
|
+
* without it nothing can be decoration, and guessing one would strip a real name.
|
|
83
|
+
* @returns {string|null} the canonical resource, or null when it is not an object identity.
|
|
84
|
+
*/
|
|
85
|
+
export function canonicaliseResource(resource, region) {
|
|
86
|
+
if (typeof resource !== 'string') return null;
|
|
87
|
+
|
|
88
|
+
// ⚠️ THE REGION-UNKNOWN PATH, and it is not a convenience — without it this function
|
|
89
|
+
// reintroduces the defect it exists to prevent, on the channel that needed it first.
|
|
90
|
+
// EE's MTTR tracker fingerprints PRIOR scans from their stored `scan_compliance_*.json`
|
|
91
|
+
// violations, and that shape has no `region`: measured over the 1.1.0 evidence tree, 3735
|
|
92
|
+
// violations, all with `resource`, ZERO with `region`. So on the prior side the exact rule
|
|
93
|
+
// below can never fire, a decorated prior written by the published build never matches a
|
|
94
|
+
// clean current, every lifecycle restarts and the SLA section reports mass remediation that
|
|
95
|
+
// did not happen.
|
|
96
|
+
//
|
|
97
|
+
// A trailing bracketed token of STRICT REGION SHAPE is therefore treated as decoration when
|
|
98
|
+
// the region is unknowable. The shape is tight because the alphabet was DERIVED from the real
|
|
99
|
+
// records rather than imagined: bracketed suffixes there are `[us-east-1]` ×286, and also
|
|
100
|
+
// `[target-1]` ×32, `[1022]`, `[1021]`, `[1030]`, `[1020]` and `[ERROR]` — none of them
|
|
101
|
+
// decoration, every one of them eaten by a loose "strip any trailing bracket" rule. Two alpha
|
|
102
|
+
// groups and a trailing number are required, which is what separates `us-east-1` from
|
|
103
|
+
// `target-1`.
|
|
104
|
+
//
|
|
105
|
+
// Rule 2 is deliberately NOT applied here: with no region to compare against, a resource of
|
|
106
|
+
// `us-east-1` is indistinguishable from an object legitimately so named, and leaving identity
|
|
107
|
+
// alone is the safe direction where nulling it is not.
|
|
108
|
+
if (typeof region !== 'string' || region.length === 0) {
|
|
109
|
+
const m = TRAILING_REGION_DECORATION.exec(resource);
|
|
110
|
+
return m ? m[1] : resource;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Rule 2 — the resource IS the region: `_stampRegion`'s missing-resource branch claimed a
|
|
114
|
+
// region as the thing a finding was about. That is scope, not an object, so it is not an
|
|
115
|
+
// identity. Checked first: it is the stronger statement of the two.
|
|
116
|
+
if (resource === region) return null;
|
|
117
|
+
|
|
118
|
+
// Rule 1 — a TRAILING bracketed suffix equal to this finding's own region is decoration.
|
|
119
|
+
// Trailing and bracketed and region-equal, all three: an ARN contains its region, a bucket may
|
|
120
|
+
// be NAMED for one, and a producer may legitimately qualify a scope literal with one. Matching
|
|
121
|
+
// any of those would collapse distinct objects onto one key, which is the masking this lane
|
|
122
|
+
// exists to close — and over-stripping is the worse direction, because it is silent.
|
|
123
|
+
const suffix = ` [${region}]`;
|
|
124
|
+
if (resource.endsWith(suffix)) return resource.slice(0, -suffix.length);
|
|
125
|
+
|
|
126
|
+
return resource;
|
|
127
|
+
}
|
package/utils/host_iterator.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fsp from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { canonicalHost } from './cloud_providers.mjs';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Parse a dotted-quad IPv4 string into a 32-bit unsigned integer.
|
|
@@ -95,7 +96,8 @@ export async function parseHostFile(filePath) {
|
|
|
95
96
|
return false;
|
|
96
97
|
}
|
|
97
98
|
return true;
|
|
98
|
-
})
|
|
99
|
+
})
|
|
100
|
+
.map(canonicalHost); // a provider named in a host file is recorded as the provider, whatever its case
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/**
|
|
@@ -172,15 +174,16 @@ export async function parseHostArg(arg) {
|
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
// Path traversal guard: reject absolute paths and paths resolving outside cwd
|
|
175
|
-
if (path.isAbsolute(arg)) return [arg]; // treat as hostname, not file
|
|
177
|
+
if (path.isAbsolute(arg)) return [canonicalHost(arg)]; // treat as hostname, not file
|
|
176
178
|
const resolved = path.resolve(arg);
|
|
177
|
-
if (!resolved.startsWith(process.cwd() + path.sep)) return [arg]; // outside CWD = hostname
|
|
179
|
+
if (!resolved.startsWith(process.cwd() + path.sep)) return [canonicalHost(arg)]; // outside CWD = hostname
|
|
178
180
|
|
|
179
181
|
try {
|
|
180
182
|
await fsp.access(arg);
|
|
181
183
|
return parseHostFile(arg);
|
|
182
184
|
} catch {
|
|
183
|
-
// Not a file — treat as single host
|
|
184
|
-
|
|
185
|
+
// Not a file — treat as single host. `--host AWS` is the provider: recorded as `aws`
|
|
186
|
+
// (see `canonicalHost`), so the record and every later comparison agree on it.
|
|
187
|
+
return [canonicalHost(arg)];
|
|
185
188
|
}
|
|
186
189
|
}
|