@blamejs/exceptd-skills 0.18.11 → 0.18.13
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 +20 -0
- package/bin/exceptd.js +12 -5
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +10 -1
- package/lib/collectors/cred-stores.js +18 -9
- package/lib/collectors/secrets.js +4 -2
- package/lib/cve-regression-watcher.js +5 -1
- package/lib/framework-gap.js +25 -14
- package/lib/job-queue.js +9 -2
- package/lib/lint-skills.js +34 -5
- package/lib/playbook-runner.js +65 -2
- package/lib/prefetch.js +15 -7
- package/lib/rfc-cli.js +7 -2
- package/lib/schemas/skill-frontmatter.schema.json +2 -2
- package/lib/scoring.js +40 -7
- package/lib/source-ghsa.js +8 -2
- package/lib/source-osv.js +19 -3
- package/lib/ttp-mapper.js +15 -0
- package/lib/upstream-check.js +17 -3
- package/lib/validate-cve-catalog.js +16 -4
- package/lib/validate-package.js +9 -2
- package/lib/validate-playbooks.js +35 -10
- package/lib/validate-vendor.js +11 -5
- package/lib/verify.js +35 -34
- package/lib/xml-tokenizer.js +36 -8
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +8 -0
- package/orchestrator/scanner.js +20 -0
- package/package.json +1 -1
- package/sbom.cdx.json +87 -87
- package/scripts/builders/cwe-chains.js +5 -2
- package/scripts/builders/summary-cards.js +12 -4
- package/scripts/check-codebase-patterns-currency.js +1 -0
- package/scripts/check-codebase-patterns.js +42 -0
- package/scripts/check-test-subjects.js +27 -6
- package/scripts/predeploy.js +19 -8
- package/scripts/refresh-upstream-catalogs.js +20 -3
- package/sources/validators/cve-validator.js +7 -1
- package/sources/validators/rfc-validator.js +21 -3
- package/sources/validators/version-pin-validator.js +39 -2
- package/vendor/blamejs/_PROVENANCE.json +3 -2
- package/vendor/blamejs/worker-pool.js +6 -0
package/lib/ttp-mapper.js
CHANGED
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* and surfaces gaps where controls fail to cover attacker techniques.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
9
|
+
|
|
8
10
|
function map(controlId, gapCatalog) {
|
|
11
|
+
// hasOwnProperty guard: a bare gapCatalog[controlId] dereferences inherited
|
|
12
|
+
// Object.prototype members, so controlId='__proto__' / 'toString' /
|
|
13
|
+
// 'constructor' returned found:true for a key that is not a real catalog
|
|
14
|
+
// entry. Only own enumerable keys are catalog controls.
|
|
15
|
+
if (!gapCatalog || !hasOwn(gapCatalog, controlId)) {
|
|
16
|
+
return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
|
|
17
|
+
}
|
|
9
18
|
const entry = gapCatalog[controlId];
|
|
10
19
|
if (!entry) return { control_id: controlId, found: false, message: 'Control not in gap catalog' };
|
|
11
20
|
return {
|
|
@@ -47,6 +56,12 @@ function coverage(frameworkId, ttpId, gapCatalog, atlasCatalog) {
|
|
|
47
56
|
return { ttp_id: ttpId, found: false, error: 'frameworkId required', partially_covered_by: null, not_covered_by: null };
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
// hasOwnProperty guard: ttpId='__proto__' / 'toString' / 'constructor'
|
|
60
|
+
// would otherwise resolve to an inherited Object.prototype member and be
|
|
61
|
+
// treated as a real ATLAS technique. Only own keys are catalog techniques.
|
|
62
|
+
if (!atlasCatalog || !hasOwn(atlasCatalog, ttpId)) {
|
|
63
|
+
return { ttp_id: ttpId, found: false };
|
|
64
|
+
}
|
|
50
65
|
const ttp = atlasCatalog[ttpId];
|
|
51
66
|
if (!ttp) return { ttp_id: ttpId, found: false };
|
|
52
67
|
|
package/lib/upstream-check.js
CHANGED
|
@@ -47,7 +47,15 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
|
|
|
47
47
|
const fs = require("fs");
|
|
48
48
|
const fixture = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
|
|
49
49
|
const version = fixture["dist-tags"]?.latest || fixture.version;
|
|
50
|
-
|
|
50
|
+
// Same guard the network branch applies: a fixture with neither
|
|
51
|
+
// dist-tags.latest nor a top-level version yields no freshness signal.
|
|
52
|
+
// Returning ok:true with version:undefined would let an undefined
|
|
53
|
+
// version leak into buildFreshnessReport's semver compare and the
|
|
54
|
+
// operator-facing hint. Degrade to an explicit offline refusal.
|
|
55
|
+
if (!version) {
|
|
56
|
+
return { ok: false, error: "fixture missing dist-tags.latest / version", source: "offline" };
|
|
57
|
+
}
|
|
58
|
+
const published = fixture.time?.[version] || fixture.time?.modified || null;
|
|
51
59
|
return { ok: true, version, published_at: published, source: "fixture" };
|
|
52
60
|
} catch (e) {
|
|
53
61
|
return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
|
|
@@ -120,8 +128,14 @@ function buildFreshnessReport({ localVersion, registry, localManifest }) {
|
|
|
120
128
|
};
|
|
121
129
|
}
|
|
122
130
|
const cmp = semverCmp(localVersion, registry.version);
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
// Parse the publish timestamp once and validate it. A non-empty but
|
|
132
|
+
// unparseable date string (e.g. a malformed registry `time` value) makes
|
|
133
|
+
// `new Date(...).getTime()` return NaN, which would otherwise propagate
|
|
134
|
+
// NaN into days_since_latest_publish. Degrade an unparseable date to the
|
|
135
|
+
// explicit null (signal-absent) branch instead of emitting NaN.
|
|
136
|
+
const publishedTs = registry.published_at ? new Date(registry.published_at).getTime() : NaN;
|
|
137
|
+
const daysBehind = Number.isFinite(publishedTs)
|
|
138
|
+
? Math.max(0, Math.floor((Date.now() - publishedTs) / (24 * 3600 * 1000)))
|
|
125
139
|
: null;
|
|
126
140
|
// Manifest's last_threat_review (per-skill) — surface the most stale.
|
|
127
141
|
let oldestReview = null;
|
|
@@ -384,8 +384,16 @@ function main() {
|
|
|
384
384
|
const schema = readJson(SCHEMA_PATH);
|
|
385
385
|
const catalog = readJson(CATALOG_PATH);
|
|
386
386
|
const lessons = readJson(LESSONS_PATH);
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
// All five reference catalogs default to null when absent so the
|
|
388
|
+
// "cannot validate — catalog absent" branch in additionalChecks fires
|
|
389
|
+
// uniformly for every ref family. atlas/cwe previously defaulted to {},
|
|
390
|
+
// which built an EMPTY Set (never null) — so an absent atlas/cwe catalog
|
|
391
|
+
// silently FLAGGED every ref as unresolved (false positive) instead of
|
|
392
|
+
// surfacing the catalog-absent warning, the asymmetric opposite of how
|
|
393
|
+
// attack/d3fend/framework behaved. Uniform null makes the absence
|
|
394
|
+
// observable rather than misclassified.
|
|
395
|
+
const atlas = fs.existsSync(ATLAS_PATH) ? readJson(ATLAS_PATH) : null;
|
|
396
|
+
const cwe = fs.existsSync(CWE_PATH) ? readJson(CWE_PATH) : null;
|
|
389
397
|
const attack = fs.existsSync(ATTACK_PATH) ? readJson(ATTACK_PATH) : null;
|
|
390
398
|
const d3fend = fs.existsSync(D3FEND_PATH) ? readJson(D3FEND_PATH) : null;
|
|
391
399
|
const frameworks = fs.existsSync(FRAMEWORK_GAPS_PATH)
|
|
@@ -393,8 +401,12 @@ function main() {
|
|
|
393
401
|
: null;
|
|
394
402
|
|
|
395
403
|
const ctx = {
|
|
396
|
-
atlasKeys:
|
|
397
|
-
|
|
404
|
+
atlasKeys: atlas
|
|
405
|
+
? new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')))
|
|
406
|
+
: null,
|
|
407
|
+
cweKeys: cwe
|
|
408
|
+
? new Set(Object.keys(cwe).filter((k) => !k.startsWith('_')))
|
|
409
|
+
: null,
|
|
398
410
|
attackKeys: attack
|
|
399
411
|
? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
|
|
400
412
|
: null,
|
package/lib/validate-package.js
CHANGED
|
@@ -150,8 +150,15 @@ function main() {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
// Size budget
|
|
154
|
-
|
|
153
|
+
// Size budget. An absent/non-numeric `size` field is NOT a pass — it means
|
|
154
|
+
// the gate cannot evaluate the budget at all. Fail loudly instead of
|
|
155
|
+
// silently skipping (the absent-field-fails-open class, symmetric with the
|
|
156
|
+
// license_sha256 / vendored_sha256 guards in validate-vendor.js): a future
|
|
157
|
+
// npm-pack output shape change that drops or renames `size` must not turn the
|
|
158
|
+
// size budget into a no-op.
|
|
159
|
+
if (typeof packInfo.size !== "number") {
|
|
160
|
+
issues.push(`npm pack output missing numeric size — cannot enforce size budget`);
|
|
161
|
+
} else if (packInfo.size > SIZE_BUDGET_BYTES) {
|
|
155
162
|
issues.push(`tarball size ${(packInfo.size / 1024 / 1024).toFixed(2)} MB exceeds budget ${(SIZE_BUDGET_BYTES / 1024 / 1024).toFixed(0)} MB`);
|
|
156
163
|
}
|
|
157
164
|
|
|
@@ -129,6 +129,25 @@ function typeOf(value) {
|
|
|
129
129
|
return typeof value;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Strict ISO calendar-date check for format:date fields. The shape regex alone
|
|
133
|
+
// accepts impossible dates — `new Date('2026-02-30T00:00:00Z')` does NOT throw,
|
|
134
|
+
// it silently rolls over to March 2 and reports a valid getTime() — so a
|
|
135
|
+
// malformed _meta.last_threat_review or changelog `date` (2026-02-30,
|
|
136
|
+
// 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
|
|
137
|
+
// parsed Y-M-D to round-trip back to the input components, mirroring
|
|
138
|
+
// parseIsoDateStrict in lib/validate-catalog-meta.js.
|
|
139
|
+
function isStrictIsoDate(value) {
|
|
140
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
141
|
+
const d = new Date(value + 'T00:00:00Z');
|
|
142
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
143
|
+
const [y, m, day] = value.split('-').map(Number);
|
|
144
|
+
return (
|
|
145
|
+
d.getUTCFullYear() === y &&
|
|
146
|
+
d.getUTCMonth() + 1 === m &&
|
|
147
|
+
d.getUTCDate() === day
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
function typeMatches(value, expected) {
|
|
133
152
|
if (Array.isArray(expected)) return expected.some((t) => typeMatches(value, t));
|
|
134
153
|
const actual = typeOf(value);
|
|
@@ -185,7 +204,7 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
185
204
|
}
|
|
186
205
|
}
|
|
187
206
|
if (schema.format === 'date') {
|
|
188
|
-
if (
|
|
207
|
+
if (!isStrictIsoDate(value)) {
|
|
189
208
|
err(`${here}: value ${JSON.stringify(value)} is not an ISO date (YYYY-MM-DD)`);
|
|
190
209
|
}
|
|
191
210
|
}
|
|
@@ -669,6 +688,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
669
688
|
// the source is the base phase or a directive override.
|
|
670
689
|
function checkRwepThreshold(rwepObj, pathPrefix) {
|
|
671
690
|
const rwep = rwepObj || {};
|
|
691
|
+
// Range check is PER-KEY and independent of the others. A directive's
|
|
692
|
+
// phase_overrides.direct.rwep_threshold is a partial fragment the runner
|
|
693
|
+
// deep-merges, so it may carry only one of the three keys — gating the
|
|
694
|
+
// whole check on all-three-present let an out-of-range single-key override
|
|
695
|
+
// (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
|
|
696
|
+
// runner. Validate each present numeric key on its own.
|
|
697
|
+
for (const k of ['close', 'monitor', 'escalate']) {
|
|
698
|
+
const v = rwep[k];
|
|
699
|
+
if (typeof v === 'number' && (v < 0 || v > 100)) {
|
|
700
|
+
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Ordering is only meaningful when the full triple is present (a partial
|
|
704
|
+
// override inherits the missing edges from the base phase, so close <=
|
|
705
|
+
// monitor <= escalate can't be evaluated on the fragment alone).
|
|
672
706
|
if (
|
|
673
707
|
typeof rwep.close === 'number' &&
|
|
674
708
|
typeof rwep.monitor === 'number' &&
|
|
@@ -679,15 +713,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
679
713
|
`${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
|
|
680
714
|
);
|
|
681
715
|
}
|
|
682
|
-
for (const [k, v] of [
|
|
683
|
-
['close', rwep.close],
|
|
684
|
-
['monitor', rwep.monitor],
|
|
685
|
-
['escalate', rwep.escalate],
|
|
686
|
-
]) {
|
|
687
|
-
if (v < 0 || v > 100) {
|
|
688
|
-
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
716
|
}
|
|
692
717
|
}
|
|
693
718
|
|
package/lib/validate-vendor.js
CHANGED
|
@@ -111,17 +111,23 @@ function main() {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
// On-disk inventory cross-check (BJS-04): the loop above is one-directional
|
|
114
|
-
// (manifest → disk). A
|
|
115
|
-
// _PROVENANCE.json would ship in the tarball and be require()'d
|
|
116
|
-
// while NOTHING verifies its integrity — an unregistered or
|
|
117
|
-
// vendored module.
|
|
114
|
+
// (manifest → disk). A loadable JS module PRESENT in vendor/blamejs/ but
|
|
115
|
+
// ABSENT from _PROVENANCE.json would ship in the tarball and be require()'d
|
|
116
|
+
// by lib code while NOTHING verifies its integrity — an unregistered or
|
|
117
|
+
// smuggled-in vendored module. Node executes `.js`, `.cjs`, and `.mjs` as
|
|
118
|
+
// code, so a `.cjs`/`.mjs` smuggled module carries the same arbitrary-code
|
|
119
|
+
// risk as a `.js` one — checking only `.js` let a renamed extension slip the
|
|
120
|
+
// gate. `.json`/`.node` are deliberately NOT swept here: `_PROVENANCE.json`
|
|
121
|
+
// itself lives in this directory and is the manifest, not a registered
|
|
122
|
+
// vendored module, so sweeping `.json` would false-positive on the manifest.
|
|
123
|
+
const LOADABLE_MODULE = /\.(c|m)?js$/;
|
|
118
124
|
const registered = new Set(
|
|
119
125
|
Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
|
|
120
126
|
);
|
|
121
127
|
const vendorDir = path.join(ROOT, "vendor", "blamejs");
|
|
122
128
|
try {
|
|
123
129
|
for (const f of fs.readdirSync(vendorDir)) {
|
|
124
|
-
if (!
|
|
130
|
+
if (!LOADABLE_MODULE.test(f)) continue;
|
|
125
131
|
if (!registered.has(f)) {
|
|
126
132
|
issues.push(`unregistered vendored file: vendor/blamejs/${f} is on disk but absent from _PROVENANCE.json — its integrity is not verified`);
|
|
127
133
|
}
|
package/lib/verify.js
CHANGED
|
@@ -342,6 +342,39 @@ function canonicalManifestBytes(manifest) {
|
|
|
342
342
|
* @param {object} manifest
|
|
343
343
|
*/
|
|
344
344
|
function verifyManifestSignature(manifest) {
|
|
345
|
+
// The key-pin fingerprint check runs FIRST — independent of whether a
|
|
346
|
+
// manifest_signature is present — so library callers (refresh-network gate,
|
|
347
|
+
// verify-shipped-tarball gate, tests, downstream `require("lib/verify")`
|
|
348
|
+
// consumers) cannot bypass the pin. Previously the pin only fired AFTER the
|
|
349
|
+
// signature-present check, so a key-substitution attacker who swapped
|
|
350
|
+
// keys/public.pem AND stripped manifest_signature got the early `missing`
|
|
351
|
+
// return and never tripped the pin — authenticating against the attacker key
|
|
352
|
+
// through the library API. Consulting the pin up front closes that on the
|
|
353
|
+
// legacy/missing path too. Honors KEYS_ROTATED=1 for legitimate rotations; a
|
|
354
|
+
// MISSING pin file fails closed (keys/EXPECTED_FINGERPRINT ships in the
|
|
355
|
+
// tarball and is committed, so its absence is the signature of a tamper that
|
|
356
|
+
// stripped the pin to hide a swapped key).
|
|
357
|
+
const publicKey = loadPublicKey();
|
|
358
|
+
if (publicKey) {
|
|
359
|
+
const liveFp = publicKeyFingerprint(publicKey);
|
|
360
|
+
const pinResult = checkExpectedFingerprint(liveFp);
|
|
361
|
+
if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
|
|
362
|
+
return {
|
|
363
|
+
status: 'invalid',
|
|
364
|
+
reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
|
|
365
|
+
fingerprint_mismatch: true,
|
|
366
|
+
expected: pinResult.expected,
|
|
367
|
+
actual: pinResult.actual,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (pinResult.status === 'no-pin') {
|
|
371
|
+
return {
|
|
372
|
+
status: 'invalid',
|
|
373
|
+
reason: `key-pin absent: keys/EXPECTED_FINGERPRINT is missing, so a swapped keys/public.pem cannot be detected. The pin ships in the tarball and is committed — restore it from the package or version control.`,
|
|
374
|
+
pin_absent: true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
345
378
|
const sig = manifest && manifest.manifest_signature;
|
|
346
379
|
if (!sig || typeof sig !== 'object') return { status: 'missing' };
|
|
347
380
|
if (typeof sig.signature_base64 !== 'string') {
|
|
@@ -359,43 +392,11 @@ function verifyManifestSignature(manifest) {
|
|
|
359
392
|
reason: `manifest_signature.algorithm must be exactly 'Ed25519' (got ${JSON.stringify(sig.algorithm)})`,
|
|
360
393
|
};
|
|
361
394
|
}
|
|
362
|
-
const publicKey = loadPublicKey();
|
|
363
395
|
if (!publicKey) {
|
|
364
396
|
return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
|
|
365
397
|
}
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// downstream consumers via `require("lib/verify")`) cannot bypass the pin.
|
|
369
|
-
// Previously the pin only fired at the CLI tail of `node lib/verify.js`,
|
|
370
|
-
// letting a coordinated attacker who swapped keys/public.pem authenticate
|
|
371
|
-
// against the attacker key without any divergence surfaced through the
|
|
372
|
-
// library API. Honors KEYS_ROTATED=1 for legitimate rotations. A MISSING pin
|
|
373
|
-
// file is now rejected too: keys/EXPECTED_FINGERPRINT ships in the tarball and
|
|
374
|
-
// is committed to the repo, so its absence is not a legacy state — it is the
|
|
375
|
-
// signature a key-substitution attack leaves when it strips the pin to hide a
|
|
376
|
-
// swapped keys/public.pem.
|
|
377
|
-
const liveFp = publicKeyFingerprint(publicKey);
|
|
378
|
-
const pinResult = checkExpectedFingerprint(liveFp);
|
|
379
|
-
if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
|
|
380
|
-
return {
|
|
381
|
-
status: 'invalid',
|
|
382
|
-
reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
|
|
383
|
-
fingerprint_mismatch: true,
|
|
384
|
-
expected: pinResult.expected,
|
|
385
|
-
actual: pinResult.actual,
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
if (pinResult.status === 'no-pin') {
|
|
389
|
-
// A missing pin fails closed unconditionally — there is nothing to override.
|
|
390
|
-
// KEYS_ROTATED only applies to a fingerprint MISMATCH (a new key + a new
|
|
391
|
-
// pin); a legitimate rotation updates the pin in place, it never removes it,
|
|
392
|
-
// so an absent pin is treated as tampering and restoring it is the only fix.
|
|
393
|
-
return {
|
|
394
|
-
status: 'invalid',
|
|
395
|
-
reason: `key-pin absent: keys/EXPECTED_FINGERPRINT is missing, so a swapped keys/public.pem cannot be detected. The pin ships in the tarball and is committed — restore it from the package or version control.`,
|
|
396
|
-
pin_absent: true,
|
|
397
|
-
};
|
|
398
|
-
}
|
|
398
|
+
// The key-pin (mismatch / no-pin) was already verified up front, before any
|
|
399
|
+
// signature branching — so reaching here means the live key matches the pin.
|
|
399
400
|
let signatureBytes;
|
|
400
401
|
try {
|
|
401
402
|
signatureBytes = Buffer.from(sig.signature_base64, 'base64');
|
package/lib/xml-tokenizer.js
CHANGED
|
@@ -400,6 +400,7 @@ function parseFeedDetailed(xml) {
|
|
|
400
400
|
let activeField = null; // active field local-name
|
|
401
401
|
let buffer = ""; // accumulator for current field text
|
|
402
402
|
let linkRank = 0; // rel-rank of the best <link> captured so far
|
|
403
|
+
let linkRel = null; // rel of the link element currently open (for its element-text close)
|
|
403
404
|
|
|
404
405
|
tokenize(xml, {
|
|
405
406
|
rawTextElements: LEAF_FIELDS,
|
|
@@ -413,6 +414,10 @@ function parseFeedDetailed(xml) {
|
|
|
413
414
|
if (FIELD_MAP[name]) {
|
|
414
415
|
activeField = FIELD_MAP[name];
|
|
415
416
|
buffer = "";
|
|
417
|
+
// Remember this link element's rel so its element-text close (below) is
|
|
418
|
+
// ranked the same way the attribute path is — a non-alternate link with
|
|
419
|
+
// text must not clobber a captured rel=alternate.
|
|
420
|
+
if (name === "link") linkRel = attrs ? attrs.rel : null;
|
|
416
421
|
// Atom <link href="..."/> — rel-aware selection. Only let an
|
|
417
422
|
// alternate / rel-absent link upgrade the captured value, and never
|
|
418
423
|
// let a non-alternate rel (self/replies/edit) clobber an alternate
|
|
@@ -442,14 +447,19 @@ function parseFeedDetailed(xml) {
|
|
|
442
447
|
if (!current) return;
|
|
443
448
|
if (FIELD_MAP[name] && activeField === FIELD_MAP[name]) {
|
|
444
449
|
const value = buffer.trim();
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
450
|
+
// <link>...</link> element text — ranked by the link's rel, the SAME
|
|
451
|
+
// gate the attribute path uses, so a non-alternate (self/replies/edit)
|
|
452
|
+
// link with text cannot clobber a captured rel=alternate. RSS links
|
|
453
|
+
// carry no rel, and relRank(undefined) === 2, so RSS element-text links
|
|
454
|
+
// keep their previous authoritative rank. An empty element-text link
|
|
455
|
+
// falls back to whatever attribute capture onTagOpen already recorded.
|
|
448
456
|
if (name === "link") {
|
|
449
|
-
|
|
457
|
+
const r = relRank(linkRel);
|
|
458
|
+
if (value && r > linkRank) {
|
|
450
459
|
current.link = value;
|
|
451
|
-
linkRank =
|
|
460
|
+
linkRank = r;
|
|
452
461
|
}
|
|
462
|
+
linkRel = null;
|
|
453
463
|
} else if (activeField === "body" || activeField === "title") {
|
|
454
464
|
// Strip HTML tags from title + description / content / summary.
|
|
455
465
|
// Many feeds embed inline HTML (<b>, <em>, <a>) in titles for
|
|
@@ -498,9 +508,27 @@ function parseFeed(xml, errors = null) {
|
|
|
498
508
|
|
|
499
509
|
function stripHtml(s) {
|
|
500
510
|
if (typeof s !== "string") return "";
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
511
|
+
// Single-pass tag strip. A backtracking /<[^>]+>/g is O(n^2) on
|
|
512
|
+
// attacker-controlled feed text with many '<' and no '>' (a ~150 KB
|
|
513
|
+
// <title>/<description> of bare '<' froze the refresh worker for seconds —
|
|
514
|
+
// a ReDoS-class DoS, since parseFeed() runs on network-fetched RSS/Atom
|
|
515
|
+
// bodies). Walk '<'..'>' instead, preserving the prior contract exactly: a
|
|
516
|
+
// real `<...>` becomes a space; a stray '<' with no closing '>' and an empty
|
|
517
|
+
// '<>' survive as literal text (the `[^>]+` quantifier required >=1 char, so
|
|
518
|
+
// neither matched). Then collapse whitespace as before.
|
|
519
|
+
let out = "";
|
|
520
|
+
let i = 0;
|
|
521
|
+
while (i < s.length) {
|
|
522
|
+
const lt = s.indexOf("<", i);
|
|
523
|
+
if (lt === -1) { out += s.slice(i); break; }
|
|
524
|
+
out += s.slice(i, lt);
|
|
525
|
+
const gt = s.indexOf(">", lt + 1);
|
|
526
|
+
if (gt === -1) { out += s.slice(lt); break; } // stray '<' -> literal
|
|
527
|
+
if (gt === lt + 1) { out += "<>"; i = gt + 1; continue; } // empty '<>' is not a tag
|
|
528
|
+
out += " "; // a real tag -> space
|
|
529
|
+
i = gt + 1;
|
|
530
|
+
}
|
|
531
|
+
return out.replace(/\s+/g, " ").trim();
|
|
504
532
|
}
|
|
505
533
|
|
|
506
534
|
module.exports = { tokenize, parseFeed, parseFeedDetailed, decodeEntities, localName, parseAttrs, stripHtml };
|