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.
@@ -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
+ }
@@ -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
- if (diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length || diff.newFindings) {
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
- export function renderExecutiveReport(model, brand, { renderedAt = new Date() } = {}) {
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
+ }
@@ -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
- return [arg];
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
  }