@blamejs/blamejs-shop 0.3.7 → 0.3.9
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/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +125 -1
- package/lib/storefront.js +1 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/.github/workflows/ci.yml +27 -0
- package/lib/vendor/blamejs/.github/workflows/codeql.yml +2 -2
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
- package/lib/vendor/blamejs/.github/workflows/scorecard.yml +1 -1
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/SECURITY.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/lib/cra-report.js +3 -3
- package/lib/vendor/blamejs/lib/middleware/age-gate.js +20 -7
- package/lib/vendor/blamejs/lib/middleware/bearer-auth.js +36 -35
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +17 -5
- package/lib/vendor/blamejs/lib/middleware/cors.js +28 -12
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +22 -14
- package/lib/vendor/blamejs/lib/middleware/daily-byte-quota.js +27 -13
- package/lib/vendor/blamejs/lib/middleware/deny-response.js +140 -0
- package/lib/vendor/blamejs/lib/middleware/dpop.js +32 -19
- package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +21 -12
- package/lib/vendor/blamejs/lib/middleware/host-allowlist.js +19 -8
- package/lib/vendor/blamejs/lib/middleware/index.js +3 -0
- package/lib/vendor/blamejs/lib/middleware/network-allowlist.js +24 -10
- package/lib/vendor/blamejs/lib/middleware/rate-limit.js +22 -5
- package/lib/vendor/blamejs/lib/middleware/require-aal.js +25 -10
- package/lib/vendor/blamejs/lib/middleware/require-auth.js +32 -16
- package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +49 -18
- package/lib/vendor/blamejs/lib/middleware/require-content-type.js +19 -8
- package/lib/vendor/blamejs/lib/middleware/require-methods.js +17 -7
- package/lib/vendor/blamejs/lib/middleware/require-mtls.js +27 -14
- package/lib/vendor/blamejs/lib/network.js +4 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.6.json +60 -0
- package/lib/vendor/blamejs/scripts/check-actions-currency.js +256 -0
- package/lib/vendor/blamejs/scripts/release.js +11 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +138 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +228 -0
- package/package.json +1 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* GitHub-Actions-currency gate — the sibling of
|
|
4
|
+
* `scripts/check-vendor-currency.js` for the CI/CD supply chain.
|
|
5
|
+
*
|
|
6
|
+
* Walks every `.github/workflows/*.yml`, reads each SHA-pinned
|
|
7
|
+
* `uses: owner/repo[/subpath]@<sha> # vX.Y.Z` reference, and asserts
|
|
8
|
+
* the pinned version (from the trailing comment the pinact discipline
|
|
9
|
+
* requires) matches the latest upstream release. A stale action
|
|
10
|
+
* becomes a release blocker HERE — caught in the pre-merge gate suite
|
|
11
|
+
* — instead of being surfaced after-the-fact by a Dependabot PR.
|
|
12
|
+
*
|
|
13
|
+
* Run locally:
|
|
14
|
+
* node scripts/check-actions-currency.js
|
|
15
|
+
* node scripts/check-actions-currency.js --json // structured output
|
|
16
|
+
* node scripts/check-actions-currency.js --warn // exit 0, print only
|
|
17
|
+
*
|
|
18
|
+
* Run in CI: the workflow passes GITHUB_TOKEN in the environment so the
|
|
19
|
+
* GitHub API gives the authenticated 5000/hour budget instead of the
|
|
20
|
+
* 60/hour unauthenticated per-IP limit. `stale` fails the gate;
|
|
21
|
+
* transient `api-error` results are advisory unless
|
|
22
|
+
* BLAMEJS_ACTIONS_CURRENCY_STRICT=1 converts them into hard fails too.
|
|
23
|
+
*
|
|
24
|
+
* Actions deliberately pinned to an older major (a new major the repo
|
|
25
|
+
* has not adopted) carry a SPECIAL_MAP entry pinning the expected
|
|
26
|
+
* major so the gate doesn't fight an intentional hold.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var fs = require("fs");
|
|
30
|
+
var path = require("path");
|
|
31
|
+
var https = require("https");
|
|
32
|
+
|
|
33
|
+
var WORKFLOWS_DIR = path.join(__dirname, "..", ".github", "workflows");
|
|
34
|
+
|
|
35
|
+
var WARN_ONLY = process.argv.indexOf("--warn") !== -1;
|
|
36
|
+
var JSON_OUT = process.argv.indexOf("--json") !== -1;
|
|
37
|
+
var TIMEOUT_MS = 10000;
|
|
38
|
+
|
|
39
|
+
// Per-action overrides. Keyed by "owner/repo".
|
|
40
|
+
// { type: "hold-major", major: N, reason: "..." } — only flag stale
|
|
41
|
+
// WITHIN the pinned major; a newer major is an intentional hold.
|
|
42
|
+
// { type: "skip", reason: "..." } — never flag.
|
|
43
|
+
var SPECIAL_MAP = {
|
|
44
|
+
// (none — every pinned action tracks upstream latest)
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function _githubGet(apiPath) {
|
|
48
|
+
return new Promise(function (resolve, reject) {
|
|
49
|
+
var headers = {
|
|
50
|
+
"User-Agent": "blamejs-actions-currency/1",
|
|
51
|
+
"Accept": "application/vnd.github+json",
|
|
52
|
+
};
|
|
53
|
+
// Authenticated requests get the 5000/hour budget. Both env names
|
|
54
|
+
// are accepted (GITHUB_TOKEN in Actions, GH_TOKEN for local gh).
|
|
55
|
+
var token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
56
|
+
if (token) headers.Authorization = "Bearer " + token;
|
|
57
|
+
var req = https.get("https://api.github.com" + apiPath, { timeout: TIMEOUT_MS, headers: headers }, function (res) {
|
|
58
|
+
var chunks = [];
|
|
59
|
+
res.on("data", function (c) { chunks.push(c); });
|
|
60
|
+
res.on("end", function () {
|
|
61
|
+
if (res.statusCode !== 200) {
|
|
62
|
+
return reject(new Error("github " + apiPath + " status " + res.statusCode));
|
|
63
|
+
}
|
|
64
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
65
|
+
catch (e) { reject(e); }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
req.on("timeout", function () { req.destroy(new Error("github " + apiPath + " timed out after " + TIMEOUT_MS + "ms")); });
|
|
69
|
+
req.on("error", reject);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function _resolveSha(ownerRepo, ref) {
|
|
74
|
+
// Resolve a tag to the COMMIT sha it points at — exactly what the
|
|
75
|
+
// pinact discipline pins (`owner/repo@<commit-sha> # tag`). The
|
|
76
|
+
// commits endpoint dereferences annotated tags to their commit.
|
|
77
|
+
var c = await _githubGet("/repos/" + ownerRepo + "/commits/" + encodeURIComponent(ref));
|
|
78
|
+
if (!c || typeof c.sha !== "string") throw new Error("could not resolve sha for " + ownerRepo + "@" + ref);
|
|
79
|
+
return c.sha;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function _latestVersion(ownerRepo) {
|
|
83
|
+
// Prefer the published "latest" release tag; fall back to the
|
|
84
|
+
// highest semver tag for actions that ship tags without GitHub
|
|
85
|
+
// Releases (e.g. ludeeus/action-shellcheck). Returns { tag, sha }
|
|
86
|
+
// so the report can hand back a ready-to-paste pin line.
|
|
87
|
+
var tag = null;
|
|
88
|
+
try {
|
|
89
|
+
var rel = await _githubGet("/repos/" + ownerRepo + "/releases/latest");
|
|
90
|
+
// Only trust the release tag when it is semver-shaped. Some repos
|
|
91
|
+
// (github/codeql-action) publish a non-semver bundle tag as their
|
|
92
|
+
// "latest release" (codeql-bundle-vX.Y.Z) while the ACTION is
|
|
93
|
+
// versioned on separate vN.N.N tags — fall through to tags then.
|
|
94
|
+
if (rel && typeof rel.tag_name === "string" && _semverParse(rel.tag_name)) tag = rel.tag_name;
|
|
95
|
+
} catch (_e) { /* fall through to tags */ }
|
|
96
|
+
if (!tag) {
|
|
97
|
+
var tags = await _githubGet("/repos/" + ownerRepo + "/tags?per_page=100");
|
|
98
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
99
|
+
throw new Error("no releases or tags for " + ownerRepo);
|
|
100
|
+
}
|
|
101
|
+
var best = null;
|
|
102
|
+
for (var i = 0; i < tags.length; i++) {
|
|
103
|
+
var p = _semverParse(tags[i].name);
|
|
104
|
+
if (p && (!best || _semverCompare(p, best.parsed) > 0)) {
|
|
105
|
+
best = { name: tags[i].name, parsed: p };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!best) throw new Error("no semver-shaped tag for " + ownerRepo);
|
|
109
|
+
tag = best.name;
|
|
110
|
+
}
|
|
111
|
+
return { tag: tag, sha: await _resolveSha(ownerRepo, tag) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _semverParse(v) {
|
|
115
|
+
// Accept partial tags (v4 / v4.1) by treating missing segments as 0.
|
|
116
|
+
var m = String(v).match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
return [parseInt(m[1], 10), parseInt(m[2] || "0", 10), parseInt(m[3] || "0", 10)];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _semverCompare(a, b) {
|
|
122
|
+
if (!a || !b) return 0;
|
|
123
|
+
for (var i = 0; i < 3; i++) {
|
|
124
|
+
if (a[i] > b[i]) return 1;
|
|
125
|
+
if (a[i] < b[i]) return -1;
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Collect distinct SHA-pinned actions across every workflow file.
|
|
131
|
+
// Returns { "owner/repo": { version, refs: [{ file, line, subpath }] } }.
|
|
132
|
+
function _collectPinnedActions() {
|
|
133
|
+
var out = {};
|
|
134
|
+
var files = fs.readdirSync(WORKFLOWS_DIR).filter(function (f) {
|
|
135
|
+
return f.endsWith(".yml") || f.endsWith(".yaml");
|
|
136
|
+
});
|
|
137
|
+
// `uses: owner/repo[/subpath]@<40-hex-sha> # vX.Y[.Z]`
|
|
138
|
+
var re = /uses:\s*([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)(\/[^@\s]+)?@([0-9a-f]{40})\s*#\s*v?(\d+(?:\.\d+){0,2})/;
|
|
139
|
+
for (var f = 0; f < files.length; f++) {
|
|
140
|
+
var rel = ".github/workflows/" + files[f];
|
|
141
|
+
var lines = fs.readFileSync(path.join(WORKFLOWS_DIR, files[f]), "utf8").split("\n");
|
|
142
|
+
for (var L = 0; L < lines.length; L++) {
|
|
143
|
+
var m = lines[L].match(re);
|
|
144
|
+
if (!m) continue;
|
|
145
|
+
var ownerRepo = m[1];
|
|
146
|
+
var subpath = m[2] || "";
|
|
147
|
+
var version = m[4];
|
|
148
|
+
if (!out[ownerRepo]) out[ownerRepo] = { version: version, refs: [] };
|
|
149
|
+
out[ownerRepo].refs.push({ file: rel, line: L + 1, subpath: subpath });
|
|
150
|
+
// If the same repo is pinned at two different versions across
|
|
151
|
+
// files, record the lowest so a partial bump still flags.
|
|
152
|
+
if (_semverCompare(_semverParse(version), _semverParse(out[ownerRepo].version)) < 0) {
|
|
153
|
+
out[ownerRepo].version = version;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function _checkOne(ownerRepo, entry) {
|
|
161
|
+
var special = SPECIAL_MAP[ownerRepo];
|
|
162
|
+
if (special && special.type === "skip") {
|
|
163
|
+
return { action: ownerRepo, status: "skipped", reason: special.reason, pinned: entry.version };
|
|
164
|
+
}
|
|
165
|
+
var pinned = _semverParse(entry.version);
|
|
166
|
+
try {
|
|
167
|
+
var info = await _latestVersion(ownerRepo);
|
|
168
|
+
var latest = _semverParse(info.tag);
|
|
169
|
+
var cmp = _semverCompare(pinned, latest);
|
|
170
|
+
var status = cmp >= 0 ? "current" : "stale";
|
|
171
|
+
if (special && special.type === "hold-major" && latest && latest[0] > special.major) {
|
|
172
|
+
// A newer major exists but the repo intentionally holds an
|
|
173
|
+
// older major — only flag stale WITHIN the held major.
|
|
174
|
+
status = "current";
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
action: ownerRepo,
|
|
178
|
+
pinned: entry.version,
|
|
179
|
+
latest: info.tag,
|
|
180
|
+
latestSha: info.sha,
|
|
181
|
+
status: status,
|
|
182
|
+
refs: entry.refs,
|
|
183
|
+
};
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return {
|
|
186
|
+
action: ownerRepo,
|
|
187
|
+
pinned: entry.version,
|
|
188
|
+
status: "api-error",
|
|
189
|
+
error: (e && e.message) || String(e),
|
|
190
|
+
refs: entry.refs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function main() {
|
|
196
|
+
var pinned = _collectPinnedActions();
|
|
197
|
+
var actions = Object.keys(pinned).sort();
|
|
198
|
+
var results = [];
|
|
199
|
+
// Sequential + polite — the action count is small and serial GETs
|
|
200
|
+
// keep us well inside the API budget on shared CI IPs.
|
|
201
|
+
for (var i = 0; i < actions.length; i++) {
|
|
202
|
+
results.push(await _checkOne(actions[i], pinned[actions[i]]));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (JSON_OUT) {
|
|
206
|
+
process.stdout.write(JSON.stringify({ results: results }, null, 2) + "\n");
|
|
207
|
+
} else {
|
|
208
|
+
process.stdout.write("[actions-currency] " + actions.length + " SHA-pinned action(s) inspected:\n");
|
|
209
|
+
for (var j = 0; j < results.length; j++) {
|
|
210
|
+
var r = results[j];
|
|
211
|
+
var label = r.status === "current" ? "OK"
|
|
212
|
+
: r.status === "stale" ? "STALE"
|
|
213
|
+
: r.status === "api-error" ? "ERR"
|
|
214
|
+
: r.status === "skipped" ? "skip"
|
|
215
|
+
: r.status;
|
|
216
|
+
var line = " [" + label + "] " + r.action + " " + r.pinned;
|
|
217
|
+
if (r.latest) line += " -> " + r.latest;
|
|
218
|
+
if (r.reason) line += " (" + r.reason + ")";
|
|
219
|
+
if (r.error) line += " (api: " + r.error + ")";
|
|
220
|
+
process.stdout.write(line + "\n");
|
|
221
|
+
// For stale entries, print a ready-to-paste pin line + every
|
|
222
|
+
// file:line that needs the bump, so updating is copy-paste.
|
|
223
|
+
if (r.status === "stale" && r.latestSha) {
|
|
224
|
+
process.stdout.write(" pin: " + r.action + "@" + r.latestSha + " # " + r.latest + "\n");
|
|
225
|
+
for (var rf = 0; rf < (r.refs || []).length; rf++) {
|
|
226
|
+
process.stdout.write(" used: " + r.refs[rf].file + ":" + r.refs[rf].line + "\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
var stale = results.filter(function (r) { return r.status === "stale"; });
|
|
233
|
+
var errored = results.filter(function (r) { return r.status === "api-error"; });
|
|
234
|
+
|
|
235
|
+
if (WARN_ONLY) {
|
|
236
|
+
if (stale.length || errored.length) {
|
|
237
|
+
process.stdout.write("[actions-currency] --warn: " + stale.length + " stale, " +
|
|
238
|
+
errored.length + " errored — exit 0 anyway\n");
|
|
239
|
+
}
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
var strictErrors = process.env.BLAMEJS_ACTIONS_CURRENCY_STRICT === "1";
|
|
244
|
+
if (stale.length > 0 || (strictErrors && errored.length > 0)) {
|
|
245
|
+
process.stdout.write("[actions-currency] FAIL — " + stale.length + " stale, " +
|
|
246
|
+
errored.length + " api-error(s). Bump the pinned SHA + version comment to the latest release.\n");
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
process.stdout.write("[actions-currency] OK — every pinned action matches the latest upstream release\n");
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch(function (e) {
|
|
254
|
+
process.stderr.write("[actions-currency] script crashed: " + (e && e.stack || e) + "\n");
|
|
255
|
+
process.exit(2);
|
|
256
|
+
});
|
|
@@ -258,6 +258,17 @@ function cmdPrepare(opts) {
|
|
|
258
258
|
_run("node", ["scripts/validate-source-comment-blocks.js"]);
|
|
259
259
|
_ok("eslint + codebase-patterns + source-comment-blocks clean");
|
|
260
260
|
|
|
261
|
+
_section("supply-chain currency");
|
|
262
|
+
// A stale SHA-pinned GitHub Action or vendored bundle becomes a
|
|
263
|
+
// release blocker HERE — with a ready-to-paste pin line in the
|
|
264
|
+
// actions report — instead of an after-the-fact Dependabot PR.
|
|
265
|
+
// Each script treats only an actually-newer upstream version as a
|
|
266
|
+
// failure; transient registry / API errors stay advisory (exit 0)
|
|
267
|
+
// so a flaky network response doesn't block the cut.
|
|
268
|
+
_run("node", ["scripts/check-actions-currency.js"]);
|
|
269
|
+
_run("node", ["scripts/check-vendor-currency.js"]);
|
|
270
|
+
_ok("github actions + vendored bundles current");
|
|
271
|
+
|
|
261
272
|
console.log("\nnext: node scripts/release.js smoke");
|
|
262
273
|
}
|
|
263
274
|
|
|
@@ -295,6 +295,7 @@ var VALID_ALLOW_CLASSES = {
|
|
|
295
295
|
"bare-json-parse": 1,
|
|
296
296
|
"bare-split-on-quoted-header": 1,
|
|
297
297
|
"console-direct": 1,
|
|
298
|
+
"deny-path-hardcoded-response": 1,
|
|
298
299
|
"duplicate-regex": 1,
|
|
299
300
|
"dynamic-regex": 1,
|
|
300
301
|
"dynamic-require": 1,
|
|
@@ -313,6 +314,7 @@ var VALID_ALLOW_CLASSES = {
|
|
|
313
314
|
"no-number-money-arithmetic": 1,
|
|
314
315
|
"numeric-opt-Infinity": 1,
|
|
315
316
|
"numeric-opt-no-bounds-check": 1,
|
|
317
|
+
"primitive-unreachable": 1,
|
|
316
318
|
"process-exit": 1,
|
|
317
319
|
"raw-byte-literal": 1,
|
|
318
320
|
"raw-hash-compare": 1,
|
|
@@ -2356,6 +2358,16 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
2356
2358
|
files: ["lib/guard-filename.js:verifyExtractionPath", "lib/hal.js:resource", "lib/vault-aad.js:_canonicalize"],
|
|
2357
2359
|
reason: "v0.13.13 — coincidental token shingle of the generic split-then-walk-segments idiom (`x.split(sep); for (...) { var seg = ...; if (...) throw/continue }`). guard-filename verifyExtractionPath walks path components refusing per-segment Windows-extraction hazards (reserved names / NTFS-ADS / trailing-dot); hal.js:resource builds a HAL resource by walking link/embedded keys; vault-aad.js:_canonicalize canonicalizes AAD key-value segments. Three unrelated domains (path safety / hypermedia link assembly / crypto AAD canonicalization) — no shared behaviour to extract; the only commonality is the universal split-and-loop control-flow shape.",
|
|
2358
2360
|
},
|
|
2361
|
+
{
|
|
2362
|
+
mode: "family-subset",
|
|
2363
|
+
files: [
|
|
2364
|
+
"lib/middleware/host-allowlist.js:create",
|
|
2365
|
+
"lib/middleware/require-auth.js:create",
|
|
2366
|
+
"lib/middleware/require-content-type.js:create",
|
|
2367
|
+
"lib/middleware/require-methods.js:create",
|
|
2368
|
+
],
|
|
2369
|
+
reason: "v0.14.6 — the shared shingle is the uniform deny-path denyResponse() call-site shape. The deny-response WRITER is already extracted to lib/middleware/deny-response.js; what repeats here is each create()'s ctx object literal ({ onDeny, problem, status, info, problemCode, problemTitle, problemDetail, contentType, body }). Each middleware passes a DIFFERENT status (405 / 415 / 421 / 401), problemCode, title, default body and headers — the literal IS the per-middleware configuration the consumer-facing onDeny / problemDetails convention drives. Consolidating further would mean a config table strictly less readable than the inline ctx, and the per-middleware values are exactly the divergence the dup detector can't see.",
|
|
2370
|
+
},
|
|
2359
2371
|
{
|
|
2360
2372
|
mode: "family-subset",
|
|
2361
2373
|
files: [
|
|
@@ -10474,7 +10486,133 @@ function testSafeGuardHasMustComposeDetector() {
|
|
|
10474
10486
|
unpaired);
|
|
10475
10487
|
}
|
|
10476
10488
|
|
|
10489
|
+
// ---- Pattern: every `@primitive b.X.Y` doc block resolves to a real
|
|
10490
|
+
// callable on the public surface (advertised-vs-actual
|
|
10491
|
+
// reachability). A wiki page that documents an
|
|
10492
|
+
// uncallable path is an operator-facing lie: an operator
|
|
10493
|
+
// following the docs gets `undefined is not a function`.
|
|
10494
|
+
//
|
|
10495
|
+
// Two shapes this catches:
|
|
10496
|
+
// - a wiring gap (the primitive's `create` exists but was never
|
|
10497
|
+
// wired into index.js, so `b.middleware.requireBoundKey` was
|
|
10498
|
+
// undefined);
|
|
10499
|
+
// - a doc-path drop (`b.cra.conformityAssessment` where the real
|
|
10500
|
+
// path is `b.cra.report.conformityAssessment`).
|
|
10501
|
+
//
|
|
10502
|
+
// Factory namespaces (the parent exposes `create`) document instance
|
|
10503
|
+
// methods on the create()-return value with namespace shorthand
|
|
10504
|
+
// (`b.auth.oauth.parseCallback` is reached via
|
|
10505
|
+
// `b.auth.oauth.create(...).parseCallback`). Those are NOT gaps, so a
|
|
10506
|
+
// parent that exposes `create` is skipped.
|
|
10507
|
+
function testPrimitiveReachability() {
|
|
10508
|
+
// class: primitive-unreachable
|
|
10509
|
+
var bSurface;
|
|
10510
|
+
try { bSurface = require("../../index.js"); }
|
|
10511
|
+
catch (_e) { check("primitive-reachability — index.js require", false); return; }
|
|
10512
|
+
|
|
10513
|
+
function resolve(dotted) {
|
|
10514
|
+
var parts = dotted.split(".");
|
|
10515
|
+
var cur = bSurface;
|
|
10516
|
+
for (var i = 1; i < parts.length; i += 1) {
|
|
10517
|
+
if (cur == null) return undefined;
|
|
10518
|
+
cur = cur[parts[i]];
|
|
10519
|
+
}
|
|
10520
|
+
return cur;
|
|
10521
|
+
}
|
|
10522
|
+
|
|
10523
|
+
// @primitive paths that legitimately don't resolve as a flat member
|
|
10524
|
+
// (documented elsewhere / intentional). Keyed by dotted name.
|
|
10525
|
+
var REACHABILITY_ALLOWLIST = {
|
|
10526
|
+
// (none — every surfaced gap is fixed in-tree)
|
|
10527
|
+
};
|
|
10528
|
+
|
|
10529
|
+
var libFiles = _libFiles();
|
|
10530
|
+
var unreachable = [];
|
|
10531
|
+
for (var i = 0; i < libFiles.length; i += 1) {
|
|
10532
|
+
var rel = _relPath(libFiles[i]);
|
|
10533
|
+
var src = fs.readFileSync(libFiles[i], "utf8");
|
|
10534
|
+
var re = /@primitive\s+(b\.[A-Za-z0-9_.]+)/g;
|
|
10535
|
+
var m;
|
|
10536
|
+
while ((m = re.exec(src)) !== null) {
|
|
10537
|
+
var name = m[1];
|
|
10538
|
+
if (REACHABILITY_ALLOWLIST[name]) continue;
|
|
10539
|
+
if (typeof resolve(name) !== "undefined") continue;
|
|
10540
|
+
var parts = name.split(".");
|
|
10541
|
+
var parentName = parts.slice(0, -1).join(".");
|
|
10542
|
+
var parent = resolve(parentName);
|
|
10543
|
+
// Flag only flat namespaces (object parent without a `create`
|
|
10544
|
+
// factory). Factory-instance shorthands are skipped.
|
|
10545
|
+
if (parent && typeof parent === "object" && typeof parent.create !== "function") {
|
|
10546
|
+
unreachable.push({
|
|
10547
|
+
file: rel,
|
|
10548
|
+
line: 1,
|
|
10549
|
+
content: "@primitive " + name + " documents a b.* path that does not resolve (parent `" +
|
|
10550
|
+
parentName + "` is a flat namespace without this member). Wire it into the " +
|
|
10551
|
+
"namespace, or correct the @primitive/@signature path.",
|
|
10552
|
+
});
|
|
10553
|
+
}
|
|
10554
|
+
}
|
|
10555
|
+
}
|
|
10556
|
+
_report("every @primitive b.X.Y doc block resolves to a callable on the public surface " +
|
|
10557
|
+
"(advertised-vs-actual reachability; factory-instance shorthands excluded)",
|
|
10558
|
+
unreachable);
|
|
10559
|
+
}
|
|
10560
|
+
|
|
10561
|
+
// ---- Pattern: every access-refusal middleware routes its deny
|
|
10562
|
+
// response through lib/middleware/deny-response.js so a
|
|
10563
|
+
// consumer can override the body / Content-Type (emit
|
|
10564
|
+
// RFC 9457 application/problem+json) via the uniform
|
|
10565
|
+
// onDeny / problemDetails opts. A new deny-path
|
|
10566
|
+
// middleware that hardcodes
|
|
10567
|
+
// `res.writeHead(<4xx/5xx>, { "Content-Type": ... })`
|
|
10568
|
+
// locks consumers out of the response shape — the
|
|
10569
|
+
// integration-friction class that blocked downstream
|
|
10570
|
+
// adoption (the rate-limit text/plain 429). ----
|
|
10571
|
+
function testDenyPathComposesDenyResponse() {
|
|
10572
|
+
// class: deny-path-hardcoded-response
|
|
10573
|
+
var MW_ROOT = path.resolve(LIB_ROOT, "middleware");
|
|
10574
|
+
// NOT access-refusals: content-servers that 4xx when the
|
|
10575
|
+
// .well-known resource isn't configured, and the CSP report-ingest
|
|
10576
|
+
// machine endpoint. Their 4xx is not a user-facing access denial.
|
|
10577
|
+
var NOT_DENY_PATH = {
|
|
10578
|
+
"lib/middleware/assetlinks.js": ".well-known content-server; 4xx = not-configured, not access-refusal",
|
|
10579
|
+
"lib/middleware/security-txt.js": ".well-known content-server; 4xx = not-configured, not access-refusal",
|
|
10580
|
+
"lib/middleware/web-app-manifest.js": "manifest content-server; 4xx = not-configured, not access-refusal",
|
|
10581
|
+
"lib/middleware/csp-report.js": "CSP report-ingest machine endpoint; 4xx = malformed-report rejection on a browser-posting sink",
|
|
10582
|
+
"lib/middleware/deny-response.js": "the shared deny-response helper itself",
|
|
10583
|
+
};
|
|
10584
|
+
var denyStatusRe = new RegExp("writeHead\\(\\s*(?:4\\d\\d|5\\d\\d|requestHelpers\\.HTTP_STATUS\\." +
|
|
10585
|
+
"(?:FORBIDDEN|UNAUTHORIZED|NOT_FOUND|BAD_REQUEST|METHOD_NOT_ALLOWED|UNSUPPORTED_MEDIA_TYPE|" +
|
|
10586
|
+
"TOO_MANY_REQUESTS|UNAVAILABLE_FOR_LEGAL_REASONS|MISDIRECTED_REQUEST))");
|
|
10587
|
+
var composesRe = /require\(\s*["']\.\/deny-response["']\s*\)/;
|
|
10588
|
+
var files = fs.readdirSync(MW_ROOT).filter(function (f) { return f.endsWith(".js"); });
|
|
10589
|
+
var violations = [];
|
|
10590
|
+
for (var i = 0; i < files.length; i += 1) {
|
|
10591
|
+
var rel = "lib/middleware/" + files[i];
|
|
10592
|
+
if (NOT_DENY_PATH[rel]) continue;
|
|
10593
|
+
var src = fs.readFileSync(path.join(MW_ROOT, files[i]), "utf8");
|
|
10594
|
+
if (!denyStatusRe.test(src)) continue; // doesn't write a deny status
|
|
10595
|
+
if (composesRe.test(src)) continue; // routes through the helper
|
|
10596
|
+
var lines = src.split("\n");
|
|
10597
|
+
var ln = 1;
|
|
10598
|
+
for (var L = 0; L < lines.length; L += 1) {
|
|
10599
|
+
if (denyStatusRe.test(lines[L])) { ln = L + 1; break; }
|
|
10600
|
+
}
|
|
10601
|
+
violations.push({
|
|
10602
|
+
file: rel,
|
|
10603
|
+
line: ln,
|
|
10604
|
+
content: "access-refusal middleware writes a deny-status response without composing deny-response.js — " +
|
|
10605
|
+
"route it through denyResponse() so consumers get onDeny / problemDetails (RFC 9457). If this " +
|
|
10606
|
+
"is NOT an access-refusal (content-server / machine endpoint), add it to NOT_DENY_PATH with a reason.",
|
|
10607
|
+
});
|
|
10608
|
+
}
|
|
10609
|
+
_report("every access-refusal middleware composes deny-response.js (onDeny / problemDetails override path)",
|
|
10610
|
+
violations);
|
|
10611
|
+
}
|
|
10612
|
+
|
|
10477
10613
|
async function run() {
|
|
10614
|
+
testPrimitiveReachability();
|
|
10615
|
+
testDenyPathComposesDenyResponse();
|
|
10478
10616
|
testNoOrphanAllowClass();
|
|
10479
10617
|
testNoRawByteLiterals();
|
|
10480
10618
|
testNoRawTimeLiterals();
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deny-path response convention — every access-refusal middleware
|
|
4
|
+
* routes its refusal through lib/middleware/deny-response.js, giving a
|
|
5
|
+
* consumer one uniform way to shape it:
|
|
6
|
+
*
|
|
7
|
+
* - default — the middleware's existing body + Content-Type
|
|
8
|
+
* (no behavior change)
|
|
9
|
+
* - problemDetails:true — RFC 9457 application/problem+json
|
|
10
|
+
* - onDeny(req,res,info) — the consumer fully owns the response; a
|
|
11
|
+
* throwing or no-op hook falls through to the
|
|
12
|
+
* default rather than hanging the request
|
|
13
|
+
*
|
|
14
|
+
* The deny-path response headers (Allow / WWW-Authenticate /
|
|
15
|
+
* Retry-After / Accept) survive every mode.
|
|
16
|
+
*
|
|
17
|
+
* The shared b.testing mocks don't capture setHeader + writeHead body
|
|
18
|
+
* + writableEnded together (problem mode writes via setHeader; default
|
|
19
|
+
* mode via writeHead), so — like require-auth-cache-control.test.js —
|
|
20
|
+
* this file rolls one complete response mock.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
var helpers = require("../helpers");
|
|
24
|
+
var b = helpers.b;
|
|
25
|
+
var check = helpers.check;
|
|
26
|
+
|
|
27
|
+
function _mkRes() {
|
|
28
|
+
var hdrs = {};
|
|
29
|
+
return {
|
|
30
|
+
statusCode: 0,
|
|
31
|
+
headersSent: false,
|
|
32
|
+
writableEnded: false,
|
|
33
|
+
body: null,
|
|
34
|
+
setHeader: function (k, v) { hdrs[k.toLowerCase()] = v; },
|
|
35
|
+
getHeader: function (k) { return hdrs[k.toLowerCase()]; },
|
|
36
|
+
writeHead: function (sc, h) {
|
|
37
|
+
this.statusCode = sc;
|
|
38
|
+
this.headersSent = true;
|
|
39
|
+
if (h && typeof h === "object") {
|
|
40
|
+
for (var k in h) if (Object.prototype.hasOwnProperty.call(h, k)) {
|
|
41
|
+
hdrs[k.toLowerCase()] = h[k];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return this;
|
|
45
|
+
},
|
|
46
|
+
end: function (x) {
|
|
47
|
+
if (x !== undefined && x !== null) this.body = x;
|
|
48
|
+
this.writableEnded = true;
|
|
49
|
+
},
|
|
50
|
+
_hdrs: hdrs,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _mkReq(opts) {
|
|
55
|
+
opts = opts || {};
|
|
56
|
+
return {
|
|
57
|
+
method: opts.method || "GET",
|
|
58
|
+
url: opts.url || "/x",
|
|
59
|
+
pathname: opts.pathname || "/x",
|
|
60
|
+
headers: opts.headers || {},
|
|
61
|
+
socket: { remoteAddress: "127.0.0.1", encrypted: false },
|
|
62
|
+
connection: {},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _json(s) { try { return JSON.parse(s); } catch (_e) { return {}; } }
|
|
67
|
+
|
|
68
|
+
async function _drive(mw, req) {
|
|
69
|
+
var res = _mkRes();
|
|
70
|
+
await mw(req, res, function () { res._nextCalled = true; });
|
|
71
|
+
return res;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function run() {
|
|
75
|
+
var denyResponse = require("../../lib/middleware/deny-response").denyResponse;
|
|
76
|
+
|
|
77
|
+
// ---- Helper unit: the three resolution modes ----
|
|
78
|
+
(function () {
|
|
79
|
+
// default mode
|
|
80
|
+
var res = _mkRes();
|
|
81
|
+
denyResponse(_mkReq(), res, {
|
|
82
|
+
status: 403, contentType: "text/plain", body: "no",
|
|
83
|
+
headers: { "Allow": "GET" },
|
|
84
|
+
info: { status: 403, reason: "x" },
|
|
85
|
+
problemTitle: "Forbidden",
|
|
86
|
+
});
|
|
87
|
+
check("denyResponse default: status + content-type + body + extra header",
|
|
88
|
+
res.statusCode === 403 && res._hdrs["content-type"] === "text/plain" &&
|
|
89
|
+
res.body === "no" && res._hdrs["allow"] === "GET");
|
|
90
|
+
|
|
91
|
+
// problem mode
|
|
92
|
+
var res2 = _mkRes();
|
|
93
|
+
denyResponse(_mkReq(), res2, {
|
|
94
|
+
problem: true, status: 403, contentType: "text/plain", body: "no",
|
|
95
|
+
headers: { "Allow": "GET" }, problemCode: "x-refused", problemTitle: "Forbidden",
|
|
96
|
+
problemDetail: "nope", info: { status: 403, reason: "x" },
|
|
97
|
+
});
|
|
98
|
+
var p = _json(res2.body);
|
|
99
|
+
check("denyResponse problem: application/problem+json + RFC 9457 fields + extra header survives",
|
|
100
|
+
res2.statusCode === 403 && res2._hdrs["content-type"] === "application/problem+json" &&
|
|
101
|
+
p.status === 403 && p.title === "Forbidden" && p.detail === "nope" &&
|
|
102
|
+
typeof p.type === "string" && res2._hdrs["allow"] === "GET");
|
|
103
|
+
|
|
104
|
+
// onDeny owns
|
|
105
|
+
var seen = null;
|
|
106
|
+
var res3 = _mkRes();
|
|
107
|
+
denyResponse(_mkReq(), res3, {
|
|
108
|
+
onDeny: function (req, rs, info) { seen = info; rs.writeHead(418, {}); rs.end("teapot"); },
|
|
109
|
+
status: 403, contentType: "text/plain", body: "no", info: { status: 403, reason: "x" },
|
|
110
|
+
});
|
|
111
|
+
check("denyResponse onDeny owns response + receives info",
|
|
112
|
+
res3.statusCode === 418 && res3.body === "teapot" && seen && seen.reason === "x");
|
|
113
|
+
|
|
114
|
+
// onDeny throws -> falls through to default (audited via onThrow)
|
|
115
|
+
var threw = false;
|
|
116
|
+
var res4 = _mkRes();
|
|
117
|
+
denyResponse(_mkReq(), res4, {
|
|
118
|
+
onDeny: function () { throw new Error("boom"); },
|
|
119
|
+
onThrow: function () { threw = true; },
|
|
120
|
+
status: 403, contentType: "text/plain", body: "default-after-throw",
|
|
121
|
+
info: { status: 403, reason: "x" },
|
|
122
|
+
});
|
|
123
|
+
check("denyResponse onDeny throws -> default written + onThrow audited",
|
|
124
|
+
res4.statusCode === 403 && res4.body === "default-after-throw" && threw === true);
|
|
125
|
+
|
|
126
|
+
// onDeny no-op (doesn't write) -> falls through to default
|
|
127
|
+
var res5 = _mkRes();
|
|
128
|
+
denyResponse(_mkReq(), res5, {
|
|
129
|
+
onDeny: function () { /* returns without writing */ },
|
|
130
|
+
status: 403, contentType: "text/plain", body: "default-after-noop",
|
|
131
|
+
info: { status: 403, reason: "x" },
|
|
132
|
+
});
|
|
133
|
+
check("denyResponse onDeny no-op -> default written (no hang)",
|
|
134
|
+
res5.statusCode === 403 && res5.body === "default-after-noop");
|
|
135
|
+
})();
|
|
136
|
+
|
|
137
|
+
// ---- require-methods (405 / text/plain) ----
|
|
138
|
+
(function () {
|
|
139
|
+
var def = _mkRes();
|
|
140
|
+
b.middleware.requireMethods(["GET"])(_mkReq({ method: "DELETE" }), def, function () {});
|
|
141
|
+
check("requireMethods default 405 text/plain + Allow",
|
|
142
|
+
def.statusCode === 405 && def._hdrs["content-type"] === "text/plain; charset=utf-8" &&
|
|
143
|
+
def._hdrs["allow"] === "GET" && def.body === "Method Not Allowed");
|
|
144
|
+
|
|
145
|
+
var pj = _mkRes();
|
|
146
|
+
b.middleware.requireMethods(["GET"], { problemDetails: true })(_mkReq({ method: "DELETE" }), pj, function () {});
|
|
147
|
+
check("requireMethods problemDetails 405 problem+json + Allow survives",
|
|
148
|
+
pj.statusCode === 405 && pj._hdrs["content-type"] === "application/problem+json" &&
|
|
149
|
+
pj._hdrs["allow"] === "GET" && _json(pj.body).status === 405);
|
|
150
|
+
})();
|
|
151
|
+
|
|
152
|
+
// ---- rate-limit (429) — X-RateLimit-* + Retry-After survive problem mode ----
|
|
153
|
+
(function () {
|
|
154
|
+
var mw = b.middleware.rateLimit({ backend: "memory", algorithm: "fixed-window", limit: 1, windowMs: 60000, problemDetails: true });
|
|
155
|
+
var req = _mkReq();
|
|
156
|
+
mw(req, _mkRes(), function () {});
|
|
157
|
+
var res = _mkRes();
|
|
158
|
+
mw(req, res, function () {});
|
|
159
|
+
check("rateLimit problemDetails 429 problem+json + X-RateLimit-Limit survives",
|
|
160
|
+
res.statusCode === 429 && res._hdrs["content-type"] === "application/problem+json" &&
|
|
161
|
+
res._hdrs["x-ratelimit-limit"] === "1" && _json(res.body).status === 429);
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
// ---- age-gate (451 / JSON envelope default) ----
|
|
165
|
+
(function () {
|
|
166
|
+
var opts = { getAge: function () { return 5; }, requireAge: 13, consentRequired: 13 };
|
|
167
|
+
var def = _mkRes();
|
|
168
|
+
b.middleware.ageGate(opts)(_mkReq(), def, function () {});
|
|
169
|
+
check("ageGate default 451 JSON envelope",
|
|
170
|
+
def.statusCode === 451 && def._hdrs["content-type"] === "application/json; charset=utf-8" &&
|
|
171
|
+
_json(def.body).parentalConsent === false);
|
|
172
|
+
|
|
173
|
+
var pj = _mkRes();
|
|
174
|
+
b.middleware.ageGate(Object.assign({ problemDetails: true }, opts))(_mkReq(), pj, function () {});
|
|
175
|
+
check("ageGate problemDetails 451 problem+json + requireAge extension",
|
|
176
|
+
pj.statusCode === 451 && pj._hdrs["content-type"] === "application/problem+json" &&
|
|
177
|
+
_json(pj.body).requireAge === 13);
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
// ---- cors (403) ----
|
|
181
|
+
(function () {
|
|
182
|
+
var mw = b.middleware.cors({ origins: ["https://ok.example"], problemDetails: true });
|
|
183
|
+
var res = _mkRes();
|
|
184
|
+
mw(_mkReq({ headers: { origin: "https://evil.example" } }), res, function () {});
|
|
185
|
+
check("cors problemDetails 403 problem+json on disallowed origin",
|
|
186
|
+
res.statusCode === 403 && res._hdrs["content-type"] === "application/problem+json");
|
|
187
|
+
})();
|
|
188
|
+
|
|
189
|
+
// ---- require-bound-key — now reachable on b.middleware + RFC 6750 challenge ----
|
|
190
|
+
(function () {
|
|
191
|
+
check("requireBoundKey reachable on b.middleware (was unwired)",
|
|
192
|
+
typeof b.middleware.requireBoundKey === "function");
|
|
193
|
+
})();
|
|
194
|
+
|
|
195
|
+
await (async function () {
|
|
196
|
+
var mw = b.middleware.requireBoundKey({
|
|
197
|
+
resolver: async function () { return { id: "k1", scopes: ["other"], boundFields: {} }; },
|
|
198
|
+
requiredScopes: ["needed"],
|
|
199
|
+
});
|
|
200
|
+
var scope = await _drive(mw, _mkReq({ headers: { authorization: "Bearer abc" } }));
|
|
201
|
+
check("requireBoundKey 403 missing-scope -> WWW-Authenticate insufficient_scope (RFC 6750)",
|
|
202
|
+
scope.statusCode === 403 && /insufficient_scope/.test(scope._hdrs["www-authenticate"] || ""));
|
|
203
|
+
var noTok = await _drive(mw, _mkReq());
|
|
204
|
+
check("requireBoundKey 401 no-token omits error code (RFC 6750 §3)",
|
|
205
|
+
noTok.statusCode === 401 && noTok._hdrs["www-authenticate"] === 'Bearer realm="api"');
|
|
206
|
+
})();
|
|
207
|
+
|
|
208
|
+
// ---- bearer-auth — requiredScopes now accepted (was rejected by validateOpts) ----
|
|
209
|
+
await (async function () {
|
|
210
|
+
var mw = b.middleware.bearerAuth({
|
|
211
|
+
verify: async function () { return { id: 1, scopes: ["read"] }; },
|
|
212
|
+
requiredScopes: ["admin"],
|
|
213
|
+
});
|
|
214
|
+
var res = await _drive(mw, _mkReq({ headers: { authorization: "Bearer good" } }));
|
|
215
|
+
check("bearerAuth requiredScopes reachable -> 403 insufficient_scope",
|
|
216
|
+
res.statusCode === 403 && /insufficient_scope/.test(res._hdrs["www-authenticate"] || "") &&
|
|
217
|
+
_json(res.body).required[0] === "admin");
|
|
218
|
+
})();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { run: run };
|
|
222
|
+
|
|
223
|
+
if (require.main === module) {
|
|
224
|
+
run().then(
|
|
225
|
+
function () { console.log("OK — " + helpers.getChecks() + " checks passed"); },
|
|
226
|
+
function (e) { console.error("FAIL:", e.stack || e); process.exit(1); }
|
|
227
|
+
);
|
|
228
|
+
}
|
package/package.json
CHANGED