infracensus-collector 1.4.1 → 1.4.3

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/dist/index.js CHANGED
@@ -14604,6 +14604,8 @@ function renderPage() {
14604
14604
  <meta name="viewport" content="width=device-width, initial-scale=1">
14605
14605
  <meta name="referrer" content="no-referrer">
14606
14606
  <title>InfraCensus Collector</title>
14607
+ <!-- An empty icon, declared so the browser never asks for /favicon.ico. -->
14608
+ <link rel="icon" href="data:,">
14607
14609
  <style>
14608
14610
  ${tokenCss()}
14609
14611
 
@@ -14786,6 +14788,16 @@ ${tokenCss()}
14786
14788
  }
14787
14789
  .bldpill .count { color: var(--muted); font-size: 0.6875rem; }
14788
14790
 
14791
+ /* A textarea cannot be type=password, and a list of logins pasted into one
14792
+ is every device password in the estate sitting in plain text on a screen
14793
+ somebody else can walk past. The -webkit-text-security property is the only thing
14794
+ that masks a multi-line field; it is supported in Chromium, Edge, Safari
14795
+ and Firefox 119+, and the unprefixed property is written beside it for
14796
+ whatever standardises. Where neither is honoured the field simply reads
14797
+ as it always did \u2014 so the toggle says SHOW/HIDE rather than promising
14798
+ secrecy the browser might not deliver. */
14799
+ textarea.masked { -webkit-text-security: disc; text-security: disc; }
14800
+
14789
14801
  .ok { color: var(--ok); } .bad { color: var(--bad); } .dim { color: var(--muted); }
14790
14802
  .warntext { color: var(--warn); }
14791
14803
 
@@ -14933,7 +14945,8 @@ ${tokenCss()}
14933
14945
  </head>
14934
14946
  <body>
14935
14947
  <main>
14936
- <!-- Login: shown whenever /ui/api/status answers 401. The only credential
14948
+ <!-- Login: shown whenever /ui/api/session says signed out, or a route behind
14949
+ the session answers 401 because it lapsed. The only credential
14937
14950
  fields here that belong to a PERSON \u2014 they are a cloud account of this
14938
14951
  collector's tenant, verified by the control plane, and nothing typed here
14939
14952
  is stored. Device credentials are a different thing entirely and live on
@@ -15919,14 +15932,32 @@ ${tokenCss()}
15919
15932
  if (b && b.getAttribute && b.getAttribute("id") === "refreshModules") refreshEntitlement(b);
15920
15933
  });
15921
15934
 
15935
+ // Signed in or not, asked BEFORE anything behind the session. A signed-out
15936
+ // page used to learn it from a 401 on /ui/api/status (and on credentials and
15937
+ // sites right after), which the browser logs as a failed request on every
15938
+ // load and every poll. null means the daemon did not answer at all.
15939
+ async function signedIn() {
15940
+ try {
15941
+ const res = await fetch("/ui/api/session", { cache: "no-store" });
15942
+ if (!res.ok) return null;
15943
+ return (await res.json()).signedIn === true;
15944
+ } catch (e) { return null; }
15945
+ }
15946
+
15947
+ // Resolves true when the app view is showing live status and false otherwise,
15948
+ // so the startup chain can skip the two directories on a signed-out load.
15922
15949
  async function refresh() {
15950
+ const live = await signedIn();
15951
+ if (live === false) { showLogin(); return false; }
15952
+ if (live === null) return false; // daemon restarting \u2014 the next tick retries
15923
15953
  try {
15924
15954
  const res = await fetch("/ui/api/status", { cache: "no-store" });
15925
- if (res.status === 401) { showLogin(); return; }
15926
- if (!res.ok) return; // transient \u2014 keep the last good view
15955
+ if (res.status === 401) { showLogin(); return false; } // lapsed between the two asks
15956
+ if (!res.ok) return false; // transient \u2014 keep the last good view
15927
15957
  render(await res.json());
15928
15958
  showApp();
15929
- } catch (e) { /* server restarting \u2014 the next tick retries */ }
15959
+ return true;
15960
+ } catch (e) { return false; /* server restarting \u2014 the next tick retries */ }
15930
15961
  }
15931
15962
 
15932
15963
  function schedulePoll() {
@@ -16601,11 +16632,24 @@ ${tokenCss()}
16601
16632
  const pasteSummary = el("summary", { style: "cursor:pointer",
16602
16633
  text: "Paste several logins at once" });
16603
16634
  pasteWrap.appendChild(pasteSummary);
16604
- const pasteBox = el("textarea", { id: prefix + "SshPaste", spellcheck: "false", rows: "4",
16605
- placeholder: "admin,P@ssw0rd,EnableSecret\\nreadonly:viewpass\\nsvc-net secret123" });
16635
+ const pasteBox = el("textarea", {
16636
+ id: prefix + "SshPaste",
16637
+ class: "masked",
16638
+ spellcheck: "false",
16639
+ rows: "4",
16640
+ placeholder: "admin,P@ssw0rd,EnableSecret\\nreadonly:viewpass\\nsvc-net secret123",
16641
+ });
16606
16642
  pasteWrap.appendChild(field("One login per line \u2014 username, password, enable secret", pasteBox,
16607
16643
  "Separate the fields with a comma, a colon, a tab, or spaces. The enable secret is optional. A password that itself contains a comma or colon has to go in the rows above, because a flat line cannot say which separator was part of the password."));
16608
16644
  const pasteBtn = el("button", { type: "button", class: "subtle", text: "Add these logins" });
16645
+ // SHOW/HIDE, because the box is masked by default and somebody has to be
16646
+ // able to check what they pasted. Toggling a class rather than swapping
16647
+ // the element keeps the text \u2014 a re-render would lose it.
16648
+ const peek = el("button", { type: "button", class: "subtle", text: "Show" });
16649
+ peek.addEventListener("click", () => {
16650
+ const masked = pasteBox.classList.toggle("masked");
16651
+ peek.textContent = masked ? "Show" : "Hide";
16652
+ });
16609
16653
  const pasteNote = el("div", { class: "hint" });
16610
16654
  pasteBtn.addEventListener("click", () => {
16611
16655
  const parsed = parsePastedLogins(pasteBox.value);
@@ -16617,26 +16661,39 @@ ${tokenCss()}
16617
16661
  // pastes six means eight, and silently discarding the two would be the
16618
16662
  // kind of data loss that is only noticed when a scan comes back short.
16619
16663
  //
16620
- // A row whose username is already listed is skipped rather than
16621
- // duplicated: the SSH loop tries each credential in turn against every
16622
- // host, so a duplicate is a second identical login attempt per device \u2014
16623
- // which on a locked-down estate is how an account lockout policy trips.
16624
- const already = sshRows.values().map((r) => trimmed(r.username)).filter(Boolean);
16664
+ // \u2500\u2500 IDENTICAL MEANS THE WHOLE CREDENTIAL, NOT THE USERNAME \u2500\u2500\u2500\u2500\u2500\u2500\u2500
16665
+ //
16666
+ // This compared USERNAMES, and that was wrong in the one way that
16667
+ // matters: fifteen lines reading admin/pass1 ... admin/pass15 are not
16668
+ // fifteen copies of one login, they are the fifteen passwords an
16669
+ // operator wants tried in order \u2014 the exact reason the box exists.
16670
+ // Fourteen of them were silently discarded.
16671
+ //
16672
+ // The rule it was reaching for is still worth keeping: the SSH loop
16673
+ // tries every credential against every host, so a genuinely identical
16674
+ // entry is a second identical login attempt per device, which is how a
16675
+ // lockout policy trips. Identical is username AND password AND enable.
16676
+ const key = (c) => JSON.stringify([c.username, c.password, c.enable || ""]);
16677
+ const already = sshRows.values()
16678
+ .filter((r) => trimmed(r.username) !== "")
16679
+ .map((r) => key({ username: trimmed(r.username), password: r.password, enable: r.enable }));
16625
16680
  let added = 0;
16626
16681
  let skipped = 0;
16627
16682
  for (const cred of parsed) {
16628
- if (already.indexOf(cred.username) >= 0) { skipped += 1; continue; }
16629
- already.push(cred.username);
16683
+ const k = key(cred);
16684
+ if (already.indexOf(k) >= 0) { skipped += 1; continue; }
16685
+ already.push(k);
16630
16686
  sshRows.add(cred);
16631
16687
  added += 1;
16632
16688
  }
16633
16689
  pasteBox.value = "";
16634
16690
  pasteNote.textContent =
16635
16691
  "Added " + added + " login" + (added === 1 ? "" : "s")
16636
- + (skipped ? ", skipped " + skipped + " already listed by that username" : "")
16692
+ + (skipped ? ", skipped " + skipped + " already listed" : "")
16637
16693
  + ". They are tried in the order shown, top first.";
16638
16694
  });
16639
16695
  pasteWrap.appendChild(pasteBtn);
16696
+ pasteWrap.appendChild(peek);
16640
16697
  pasteWrap.appendChild(pasteNote);
16641
16698
  host.appendChild(pasteWrap);
16642
16699
  host.appendChild(el("h3", { text: "Windows logins (WMI enrichment)" }));
@@ -17446,8 +17503,10 @@ ${tokenCss()}
17446
17503
 
17447
17504
  // Status first, then the two directories this page offers choices from. The
17448
17505
  // site list lands after the status seed on purpose: the seed points the picker
17449
- // at the configured id, and this only resolves it into a name.
17450
- refresh().then(loadCredentials).then(loadSites);
17506
+ // at the configured id, and this only resolves it into a name. Signed out,
17507
+ // neither directory is asked for: both sit behind the session and would only
17508
+ // answer 401. A successful login loads them itself.
17509
+ refresh().then((ok) => { if (ok) return loadCredentials().then(loadSites); });
17451
17510
  schedulePoll();
17452
17511
  })();
17453
17512
  </script>
@@ -18455,6 +18514,15 @@ async function startUiServer(deps, port = resolveUiPort()) {
18455
18514
  json(res, 200, { ok: true }, { "set-cookie": auth.clearCookie() });
18456
18515
  return;
18457
18516
  }
18517
+ if (method === "GET" && url === "/favicon.ico") {
18518
+ res.writeHead(204, { "cache-control": "max-age=86400" });
18519
+ res.end();
18520
+ return;
18521
+ }
18522
+ if (url === "/ui/api/session" && method === "GET") {
18523
+ json(res, 200, { signedIn: auth.verify(req.headers.cookie) });
18524
+ return;
18525
+ }
18458
18526
  if (!auth.verify(req.headers.cookie)) {
18459
18527
  json(res, 401, UNAUTHORIZED_BODY);
18460
18528
  return;