@gigzen/populace 1.3.0 → 1.3.2
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/package.json +1 -1
- package/src/cli.mjs +11 -1
- package/src/config.mjs +48 -4
- package/src/html-report.mjs +4 -4
- package/src/selftest.mjs +72 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -336,7 +336,17 @@ async function run() {
|
|
|
336
336
|
});
|
|
337
337
|
process.stdin.on("error", () => {}); // a closed pipe is not a reason to fail a run
|
|
338
338
|
process.stdin.resume();
|
|
339
|
-
|
|
339
|
+
/* unref() is NOT on every stdin. Node hands you a different stream
|
|
340
|
+
depending on what stdin is attached to, and only some of them are
|
|
341
|
+
sockets: with stdin redirected from a file or the null device — CI
|
|
342
|
+
runners, `docker run` without -i, most automation — it is an
|
|
343
|
+
fs.ReadStream, which has no unref, and calling it threw
|
|
344
|
+
"process.stdin.unref is not a function" before the run had started.
|
|
345
|
+
This whole branch exists to make non-interactive use work, so it was
|
|
346
|
+
failing in exactly the case it was written for. Nothing is lost when it
|
|
347
|
+
is absent: those streams reach EOF immediately and never hold the loop
|
|
348
|
+
open, which is all unref was buying. */
|
|
349
|
+
process.stdin.unref?.();
|
|
340
350
|
}
|
|
341
351
|
|
|
342
352
|
await world.populate();
|
package/src/config.mjs
CHANGED
|
@@ -16,6 +16,40 @@ export class ConfigError extends Error {}
|
|
|
16
16
|
|
|
17
17
|
const strip = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The hostname inside a string, however it was written.
|
|
21
|
+
*
|
|
22
|
+
* Everything here is a variation a person actually types: a trailing slash, a
|
|
23
|
+
* path on the end, no scheme at all, a port, capitals, stray whitespace. All of
|
|
24
|
+
* them must come out as the same host, because all of them are the same
|
|
25
|
+
* database.
|
|
26
|
+
*
|
|
27
|
+
* Returns null for a string that is not a host. That matters more than it
|
|
28
|
+
* looks: the denylist is compared against every string in the target block,
|
|
29
|
+
* keys and schema names included, and those must not be mistaken for hosts.
|
|
30
|
+
*/
|
|
31
|
+
function hostOf(value) {
|
|
32
|
+
const s = strip(value).replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^[^/@]*@/, "");
|
|
33
|
+
const host = s.split(/[/?#]/)[0].split(":")[0];
|
|
34
|
+
return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(host) ? host.replace(/^\.+|\.+$/g, "") : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Is this target one of the hosts the customer forbade?
|
|
39
|
+
*
|
|
40
|
+
* Host equality, or a subdomain of a forbidden host — so naming
|
|
41
|
+
* `example.com` also stops `api.example.com`, which is what someone listing
|
|
42
|
+
* their production domain means.
|
|
43
|
+
*
|
|
44
|
+
* It is deliberately NOT a substring test. A substring test refuses
|
|
45
|
+
* `key: "k"` because some forbidden URL happens to contain the letter k, and a
|
|
46
|
+
* guard that cries wolf over a single character is a guard people switch off.
|
|
47
|
+
*/
|
|
48
|
+
function sameHost(target, denied) {
|
|
49
|
+
if (!target || !denied) return false;
|
|
50
|
+
return target === denied || target.endsWith(`.${denied}`) || denied.endsWith(`.${target}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
19
53
|
/** Pull every string out of a nested object, so we can scan a whole target block. */
|
|
20
54
|
function stringsIn(value, found = []) {
|
|
21
55
|
if (typeof value === "string") found.push(value);
|
|
@@ -134,14 +168,24 @@ export function guardProduction(config) {
|
|
|
134
168
|
.split(",")
|
|
135
169
|
.map((s) => s.trim())
|
|
136
170
|
.filter(Boolean),
|
|
137
|
-
]
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
// A denied entry that is not a host is kept as a literal, so a customer who
|
|
174
|
+
// writes something unusual in there still gets an exact match rather than
|
|
175
|
+
// silently nothing.
|
|
176
|
+
const deniedHosts = denied.map(hostOf).filter(Boolean);
|
|
177
|
+
const deniedLiterals = denied.filter((d) => !hostOf(d)).map(strip).filter(Boolean);
|
|
138
178
|
|
|
139
179
|
if (denied.length) {
|
|
140
|
-
const targets = stringsIn(config.target)
|
|
141
|
-
const hit = targets.find((
|
|
180
|
+
const targets = stringsIn(config.target);
|
|
181
|
+
const hit = targets.find((raw) => {
|
|
182
|
+
const host = hostOf(raw);
|
|
183
|
+
if (host && deniedHosts.some((d) => sameHost(host, d))) return true;
|
|
184
|
+
return deniedLiterals.includes(strip(raw));
|
|
185
|
+
});
|
|
142
186
|
if (hit) {
|
|
143
187
|
refuse(
|
|
144
|
-
`The target matches a host listed in neverRunAgainst:\n ${hit}`,
|
|
188
|
+
`The target matches a host listed in neverRunAgainst:\n ${hostOf(hit) || strip(hit)}`,
|
|
145
189
|
`Simulated people must never be visible to real users.\n` +
|
|
146
190
|
` Point \`target\` at a separate test environment.`,
|
|
147
191
|
);
|
package/src/html-report.mjs
CHANGED
|
@@ -78,20 +78,20 @@ export function renderHtmlReport(report) {
|
|
|
78
78
|
<style>
|
|
79
79
|
:root{
|
|
80
80
|
--paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;--muted:#6B7883;
|
|
81
|
-
--rule:#DDE2E7;--accent:#
|
|
81
|
+
--rule:#DDE2E7;--accent:#4338CA;--ok:#2C6E4C;--bad:#A63127;--warn:#9A6A08;
|
|
82
82
|
--bar:#C9D4DC;--barbad:#E0A99F;
|
|
83
83
|
--mono:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
|
|
84
84
|
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
|
|
85
85
|
}
|
|
86
86
|
@media (prefers-color-scheme:dark){:root{
|
|
87
87
|
--paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;--muted:#7B8994;
|
|
88
|
-
--rule:#26323A;--accent:#
|
|
88
|
+
--rule:#26323A;--accent:#A5B4FC;--ok:#5FAB80;--bad:#DE7568;--warn:#D2A24E;
|
|
89
89
|
--bar:#2E3A43;--barbad:#5C332C;}}
|
|
90
90
|
:root[data-theme="dark"]{--paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;
|
|
91
|
-
--muted:#7B8994;--rule:#26323A;--accent:#
|
|
91
|
+
--muted:#7B8994;--rule:#26323A;--accent:#A5B4FC;--ok:#5FAB80;--bad:#DE7568;
|
|
92
92
|
--warn:#D2A24E;--bar:#2E3A43;--barbad:#5C332C;}
|
|
93
93
|
:root[data-theme="light"]{--paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;
|
|
94
|
-
--muted:#6B7883;--rule:#DDE2E7;--accent:#
|
|
94
|
+
--muted:#6B7883;--rule:#DDE2E7;--accent:#4338CA;--ok:#2C6E4C;--bad:#A63127;
|
|
95
95
|
--warn:#9A6A08;--bar:#C9D4DC;--barbad:#E0A99F;}
|
|
96
96
|
*{box-sizing:border-box}
|
|
97
97
|
body{margin:0;background:var(--paper);color:var(--body);font-family:var(--sans);
|
package/src/selftest.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
normaliseError,
|
|
26
26
|
summarise,
|
|
27
27
|
} from "./instrument.mjs";
|
|
28
|
+
import { guardProduction } from "./config.mjs";
|
|
28
29
|
import { buildReport, renderReport } from "./report.mjs";
|
|
29
30
|
import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
|
|
30
31
|
import { diagnose } from "./diagnose.mjs";
|
|
@@ -506,6 +507,77 @@ check("doctor names the cleanup mode", () => {
|
|
|
506
507
|
);
|
|
507
508
|
});
|
|
508
509
|
|
|
510
|
+
// --- 4c-bis. the denylist, which is the one thing that must never be wrong --
|
|
511
|
+
//
|
|
512
|
+
// Everything else in this file protects a report. This protects a database
|
|
513
|
+
// that real people's accounts are in. It had no coverage until a substring
|
|
514
|
+
// match was found refusing `key: "k"` — and the same test showed the far worse
|
|
515
|
+
// half: because the comparison included the scheme, writing the forbidden host
|
|
516
|
+
// as http:// instead of https:// walked straight past it.
|
|
517
|
+
//
|
|
518
|
+
// So the rule is hosts, not strings, and both directions are asserted: every
|
|
519
|
+
// way of spelling a forbidden host is refused, and nothing else is.
|
|
520
|
+
|
|
521
|
+
const FORBIDDEN = "prod.example.com";
|
|
522
|
+
const guarded = (target) => {
|
|
523
|
+
try {
|
|
524
|
+
guardProduction({
|
|
525
|
+
_file: "x", environment: "test",
|
|
526
|
+
neverRunAgainst: [`https://${FORBIDDEN}`], target,
|
|
527
|
+
});
|
|
528
|
+
return "ran";
|
|
529
|
+
} catch { return "refused"; }
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
for (const [how, target] of Object.entries({
|
|
533
|
+
"written plainly": `https://${FORBIDDEN}`,
|
|
534
|
+
"with no scheme": FORBIDDEN,
|
|
535
|
+
"over http instead of https": `http://${FORBIDDEN}`,
|
|
536
|
+
"as a websocket url": `wss://${FORBIDDEN}/realtime/v1`,
|
|
537
|
+
"as a postgres connection string": `postgresql://postgres:pw@${FORBIDDEN}:5432/postgres`,
|
|
538
|
+
"with credentials in front of it": `https://user:secret@${FORBIDDEN}`,
|
|
539
|
+
"with a port": `https://${FORBIDDEN}:443`,
|
|
540
|
+
"with a path, query and hash": `https://${FORBIDDEN}/rest/v1?a=1#b`,
|
|
541
|
+
"in capitals with a trailing slash": `HTTPS://${FORBIDDEN.toUpperCase()}/`,
|
|
542
|
+
"padded with whitespace": ` https://${FORBIDDEN} `,
|
|
543
|
+
"as a subdomain of it": `https://api.${FORBIDDEN}`,
|
|
544
|
+
"buried in a nested field": { url: "http://127.0.0.1:54321", replica: `https://${FORBIDDEN}` },
|
|
545
|
+
"hidden in an array": ["http://127.0.0.1:54321", `https://${FORBIDDEN}`],
|
|
546
|
+
})) {
|
|
547
|
+
check(`a forbidden host is refused ${how}`, () => {
|
|
548
|
+
assert.equal(guarded(target), "refused",
|
|
549
|
+
"this is the guard that keeps simulated people out of a real database");
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
check("a short field value is not mistaken for a forbidden host", () => {
|
|
554
|
+
// "e", "com" and "pro" are all substrings of prod.example.com. None is a host.
|
|
555
|
+
assert.equal(guarded({ url: "http://127.0.0.1:54321", key: "e", schema: "com", pool: "pro" }), "ran");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
check("a different host on the same domain still runs", () => {
|
|
559
|
+
assert.equal(guarded({ url: "https://staging.example.com", key: "k" }), "ran");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
check("naming a domain also covers its subdomains", () => {
|
|
563
|
+
// Someone who forbids example.com means all of it, not the apex alone.
|
|
564
|
+
assert.equal(guarded(`https://api.${FORBIDDEN}`), "refused");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
check("an empty denylist warns rather than silently allowing", () => {
|
|
568
|
+
const c = guardProduction({ _file: "x", environment: "test", target: { url: "https://anything.example" } });
|
|
569
|
+
assert.ok((c._warnings || []).some((w) => /neverRunAgainst is empty/.test(w)));
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
check("a non-production environment is still required", () => {
|
|
573
|
+
for (const environment of ["production", "prod", undefined, "", "live"]) {
|
|
574
|
+
assert.throws(() => guardProduction({
|
|
575
|
+
_file: "x", environment, target: { url: "http://127.0.0.1:54321" },
|
|
576
|
+
neverRunAgainst: [`https://${FORBIDDEN}`],
|
|
577
|
+
}), `environment "${environment}" must not be accepted`);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
509
581
|
// --- 4d. teardown must not claim removals it did not make ------------------
|
|
510
582
|
// selfDestruct() does nothing when there is no account and no deleteUser, and
|
|
511
583
|
// teardown counted both as successes — so a run could print "Cleanup complete
|