@mitre/hdf-diff 3.2.0 → 3.3.1
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/README.md +1 -1
- package/dist/index.d.ts +39 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +504 -50
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateComparison as validateComparison$1 } from "@mitre/hdf-validators";
|
|
2
|
+
import { parseCvssVector } from "@mitre/hdf-utilities";
|
|
2
3
|
//#region src/status.ts
|
|
3
4
|
/** Status severity ranking — higher index = worse */
|
|
4
5
|
const STATUS_SEVERITY = [
|
|
@@ -9,9 +10,9 @@ const STATUS_SEVERITY = [
|
|
|
9
10
|
"error"
|
|
10
11
|
];
|
|
11
12
|
/** Statuses that count as "passing" for fixed/regressed classification */
|
|
12
|
-
const PASSING_STATUSES = new Set(["passed"]);
|
|
13
|
+
const PASSING_STATUSES = /* @__PURE__ */ new Set(["passed"]);
|
|
13
14
|
/** Statuses that count as "failing" for fixed/regressed classification */
|
|
14
|
-
const FAILING_STATUSES = new Set([
|
|
15
|
+
const FAILING_STATUSES = /* @__PURE__ */ new Set([
|
|
15
16
|
"failed",
|
|
16
17
|
"error",
|
|
17
18
|
"notReviewed"
|
|
@@ -406,6 +407,65 @@ function extractCcis$1(req) {
|
|
|
406
407
|
return cci.filter((c) => typeof c === "string");
|
|
407
408
|
}
|
|
408
409
|
/**
|
|
410
|
+
* Extract CWE identifiers from a requirement.
|
|
411
|
+
*
|
|
412
|
+
* Prefers the structured `req.cwe[]` field (introduced in Evaluated_Requirement
|
|
413
|
+
* Wave 1) when present and non-empty. Falls back to `tags.cwe` for compatibility
|
|
414
|
+
* with HDF files emitted before the structured field existed. `tags.cwe` may be
|
|
415
|
+
* either an array of strings or a single string.
|
|
416
|
+
*
|
|
417
|
+
* Exported for reuse by sibling matchers (e.g. srg-cci-tiebreak) and to make
|
|
418
|
+
* the preference order directly testable.
|
|
419
|
+
*/
|
|
420
|
+
function extractCwes$1(req) {
|
|
421
|
+
const cwe = req["cwe"];
|
|
422
|
+
if (Array.isArray(cwe)) {
|
|
423
|
+
const filtered = cwe.filter((c) => typeof c === "string");
|
|
424
|
+
if (filtered.length > 0) return filtered;
|
|
425
|
+
}
|
|
426
|
+
const tags = req["tags"];
|
|
427
|
+
if (!tags) return [];
|
|
428
|
+
const tagCwe = tags["cwe"];
|
|
429
|
+
if (Array.isArray(tagCwe)) return tagCwe.filter((c) => typeof c === "string");
|
|
430
|
+
if (typeof tagCwe === "string") return [tagCwe];
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Build a key → [requirement-indices] map for unambiguous-pair lookup.
|
|
435
|
+
*/
|
|
436
|
+
function indexByKey(reqs, keys) {
|
|
437
|
+
const map = /* @__PURE__ */ new Map();
|
|
438
|
+
for (let i = 0; i < reqs.length; i++) for (const key of keys(reqs[i])) {
|
|
439
|
+
const list = map.get(key);
|
|
440
|
+
if (list) list.push(i);
|
|
441
|
+
else map.set(key, [i]);
|
|
442
|
+
}
|
|
443
|
+
return map;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Pair indices by an unambiguous shared key (exactly 1 old + 1 new per key),
|
|
447
|
+
* skipping any indices already claimed in `matchedOld` / `matchedNew`.
|
|
448
|
+
*/
|
|
449
|
+
function pairUnambiguous(oldMap, newMap, matchedOld, matchedNew) {
|
|
450
|
+
const pairs = [];
|
|
451
|
+
const allKeys = /* @__PURE__ */ new Set([...oldMap.keys(), ...newMap.keys()]);
|
|
452
|
+
for (const key of allKeys) {
|
|
453
|
+
const oldIndices = oldMap.get(key) ?? [];
|
|
454
|
+
const newIndices = newMap.get(key) ?? [];
|
|
455
|
+
if (oldIndices.length !== 1 || newIndices.length !== 1) continue;
|
|
456
|
+
const oldIdx = oldIndices[0];
|
|
457
|
+
const newIdx = newIndices[0];
|
|
458
|
+
if (matchedOld.has(oldIdx) || matchedNew.has(newIdx)) continue;
|
|
459
|
+
pairs.push({
|
|
460
|
+
oldIdx,
|
|
461
|
+
newIdx
|
|
462
|
+
});
|
|
463
|
+
matchedOld.add(oldIdx);
|
|
464
|
+
matchedNew.add(newIdx);
|
|
465
|
+
}
|
|
466
|
+
return pairs;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
409
469
|
* Create a CCI-based matching strategy.
|
|
410
470
|
*
|
|
411
471
|
* Matches requirements that share the same CCI identifier in `tags.cci`.
|
|
@@ -414,58 +474,51 @@ function extractCcis$1(req) {
|
|
|
414
474
|
* by multiple old or multiple new requirements) are skipped, and those
|
|
415
475
|
* requirements are left unmatched.
|
|
416
476
|
*
|
|
417
|
-
*
|
|
477
|
+
* As a fallback, also pairs unambiguous CWE matches (preferring the
|
|
478
|
+
* structured `req.cwe[]` field over the legacy `tags.cwe`). This catches
|
|
479
|
+
* CVE-ecosystem findings where CCI is absent but CWE is present.
|
|
480
|
+
*
|
|
481
|
+
* Confidence is 0.8 for CCI matches and 0.6 for CWE-fallback matches.
|
|
418
482
|
*/
|
|
419
483
|
function createCciMatchStrategy() {
|
|
420
484
|
return {
|
|
421
485
|
name: "cciMatch",
|
|
422
486
|
match(oldReqs, newReqs) {
|
|
423
|
-
const result = {
|
|
424
|
-
matched: [],
|
|
425
|
-
unmatchedOld: [],
|
|
426
|
-
unmatchedNew: []
|
|
427
|
-
};
|
|
428
|
-
const oldCciMap = /* @__PURE__ */ new Map();
|
|
429
|
-
for (let i = 0; i < oldReqs.length; i++) for (const cci of extractCcis$1(oldReqs[i])) {
|
|
430
|
-
const list = oldCciMap.get(cci);
|
|
431
|
-
if (list) list.push(i);
|
|
432
|
-
else oldCciMap.set(cci, [i]);
|
|
433
|
-
}
|
|
434
|
-
const newCciMap = /* @__PURE__ */ new Map();
|
|
435
|
-
for (let i = 0; i < newReqs.length; i++) for (const cci of extractCcis$1(newReqs[i])) {
|
|
436
|
-
const list = newCciMap.get(cci);
|
|
437
|
-
if (list) list.push(i);
|
|
438
|
-
else newCciMap.set(cci, [i]);
|
|
439
|
-
}
|
|
440
487
|
const matchedOldIndices = /* @__PURE__ */ new Set();
|
|
441
488
|
const matchedNewIndices = /* @__PURE__ */ new Set();
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
for (let i = 0; i <
|
|
461
|
-
|
|
489
|
+
const matched = [];
|
|
490
|
+
const oldCciMap = indexByKey(oldReqs, extractCcis$1);
|
|
491
|
+
const newCciMap = indexByKey(newReqs, extractCcis$1);
|
|
492
|
+
for (const { oldIdx, newIdx } of pairUnambiguous(oldCciMap, newCciMap, matchedOldIndices, matchedNewIndices)) matched.push({
|
|
493
|
+
oldReq: oldReqs[oldIdx],
|
|
494
|
+
newReq: newReqs[newIdx],
|
|
495
|
+
strategy: "cciMatch",
|
|
496
|
+
confidence: .8
|
|
497
|
+
});
|
|
498
|
+
const oldCweMap = indexByKey(oldReqs, (r) => extractCwes$1(r));
|
|
499
|
+
const newCweMap = indexByKey(newReqs, (r) => extractCwes$1(r));
|
|
500
|
+
for (const { oldIdx, newIdx } of pairUnambiguous(oldCweMap, newCweMap, matchedOldIndices, matchedNewIndices)) matched.push({
|
|
501
|
+
oldReq: oldReqs[oldIdx],
|
|
502
|
+
newReq: newReqs[newIdx],
|
|
503
|
+
strategy: "cciMatch",
|
|
504
|
+
confidence: .6
|
|
505
|
+
});
|
|
506
|
+
const unmatchedOld = [];
|
|
507
|
+
for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) unmatchedOld.push(oldReqs[i]);
|
|
508
|
+
const unmatchedNew = [];
|
|
509
|
+
for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) unmatchedNew.push(newReqs[i]);
|
|
510
|
+
return {
|
|
511
|
+
matched,
|
|
512
|
+
unmatchedOld,
|
|
513
|
+
unmatchedNew
|
|
514
|
+
};
|
|
462
515
|
}
|
|
463
516
|
};
|
|
464
517
|
}
|
|
465
518
|
//#endregion
|
|
466
519
|
//#region src/matching/fuzzy-match.ts
|
|
467
520
|
/** Common English stop words to exclude from tokenization */
|
|
468
|
-
const STOP_WORDS = new Set([
|
|
521
|
+
const STOP_WORDS = /* @__PURE__ */ new Set([
|
|
469
522
|
"a",
|
|
470
523
|
"an",
|
|
471
524
|
"the",
|
|
@@ -756,6 +809,28 @@ function extractCcis(req) {
|
|
|
756
809
|
return new Set(cci.filter((c) => typeof c === "string"));
|
|
757
810
|
}
|
|
758
811
|
/**
|
|
812
|
+
* Extract CWE identifiers from a requirement.
|
|
813
|
+
*
|
|
814
|
+
* Prefers the structured `req.cwe[]` field (introduced in Evaluated_Requirement
|
|
815
|
+
* Wave 1) when present and non-empty. Falls back to `tags.cwe` (array or string)
|
|
816
|
+
* for compatibility with HDF files emitted before the structured field existed.
|
|
817
|
+
*
|
|
818
|
+
* Exported so callers and tests can verify the preference order directly.
|
|
819
|
+
*/
|
|
820
|
+
function extractCwes(req) {
|
|
821
|
+
const cwe = req["cwe"];
|
|
822
|
+
if (Array.isArray(cwe)) {
|
|
823
|
+
const filtered = cwe.filter((c) => typeof c === "string");
|
|
824
|
+
if (filtered.length > 0) return new Set(filtered);
|
|
825
|
+
}
|
|
826
|
+
const tags = req["tags"];
|
|
827
|
+
if (!tags) return /* @__PURE__ */ new Set();
|
|
828
|
+
const tagCwe = tags["cwe"];
|
|
829
|
+
if (Array.isArray(tagCwe)) return new Set(tagCwe.filter((c) => typeof c === "string"));
|
|
830
|
+
if (typeof tagCwe === "string") return /* @__PURE__ */ new Set([tagCwe]);
|
|
831
|
+
return /* @__PURE__ */ new Set();
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
759
834
|
* Extract tags.gtitle from a requirement.
|
|
760
835
|
*/
|
|
761
836
|
function extractGtitle(req) {
|
|
@@ -839,21 +914,26 @@ function createSrgCciTiebreakStrategy() {
|
|
|
839
914
|
if (matchedNewIndices.has(ni)) continue;
|
|
840
915
|
const newCcis = extractCcis(newReqs[ni]);
|
|
841
916
|
const newTitle = safeTitle$1(newReqs[ni]);
|
|
917
|
+
const newCwes = extractCwes(newReqs[ni]);
|
|
842
918
|
let bestIdx = -1;
|
|
843
919
|
let bestComposite = -1;
|
|
844
920
|
let bestCci = 0;
|
|
921
|
+
let bestCwe = -1;
|
|
845
922
|
let bestIsUnclaimed = false;
|
|
846
923
|
for (const oi of oldIdxList) {
|
|
847
924
|
const oldCcis = extractCcis(oldReqs[oi]);
|
|
848
925
|
const oldTitle = safeTitle$1(oldReqs[oi]);
|
|
926
|
+
const oldCwes = extractCwes(oldReqs[oi]);
|
|
849
927
|
const cci = cciJaccard(newCcis, oldCcis);
|
|
850
928
|
const title = tokenJaccard(newTitle, oldTitle);
|
|
929
|
+
const cwe = cciJaccard(newCwes, oldCwes);
|
|
851
930
|
const composite = CCI_WEIGHT * cci + TITLE_WEIGHT * title;
|
|
852
931
|
const isUnclaimed = !matchedOldIndices.has(oi);
|
|
853
|
-
if (bestIdx === -1 || isUnclaimed && !bestIsUnclaimed || isUnclaimed === bestIsUnclaimed && composite > bestComposite) {
|
|
932
|
+
if (bestIdx === -1 || isUnclaimed && !bestIsUnclaimed || isUnclaimed === bestIsUnclaimed && composite > bestComposite || isUnclaimed === bestIsUnclaimed && composite === bestComposite && cwe > bestCwe) {
|
|
854
933
|
bestIdx = oi;
|
|
855
934
|
bestComposite = composite;
|
|
856
935
|
bestCci = cci;
|
|
936
|
+
bestCwe = cwe;
|
|
857
937
|
bestIsUnclaimed = isUnclaimed;
|
|
858
938
|
}
|
|
859
939
|
}
|
|
@@ -888,7 +968,7 @@ function createSrgCciTiebreakStrategy() {
|
|
|
888
968
|
/** Default threshold for accepting a Levenshtein match. */
|
|
889
969
|
const DEFAULT_ACCEPT_THRESHOLD = .45;
|
|
890
970
|
/** Modal verbs that mark the start of compliance statements. */
|
|
891
|
-
const COMPLIANCE_MODALS = new Set([
|
|
971
|
+
const COMPLIANCE_MODALS = /* @__PURE__ */ new Set([
|
|
892
972
|
"must",
|
|
893
973
|
"will",
|
|
894
974
|
"shall",
|
|
@@ -1482,7 +1562,7 @@ function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, t
|
|
|
1482
1562
|
for (const b of oldBaselines) oldBaselineMap.set(b.name, b);
|
|
1483
1563
|
const newBaselineMap = /* @__PURE__ */ new Map();
|
|
1484
1564
|
for (const b of newBaselines) newBaselineMap.set(b.name, b);
|
|
1485
|
-
const allBaselineNames = new Set([...oldBaselineMap.keys(), ...newBaselineMap.keys()]);
|
|
1565
|
+
const allBaselineNames = /* @__PURE__ */ new Set([...oldBaselineMap.keys(), ...newBaselineMap.keys()]);
|
|
1486
1566
|
const baselineDiffs = [];
|
|
1487
1567
|
for (const name of allBaselineNames) {
|
|
1488
1568
|
const oldB = oldBaselineMap.get(name);
|
|
@@ -1575,13 +1655,26 @@ function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, t
|
|
|
1575
1655
|
requirementDiffs
|
|
1576
1656
|
};
|
|
1577
1657
|
}
|
|
1658
|
+
/** Field names handled by dedicated CVE-ecosystem diff handlers (not generic JSON compare). */
|
|
1659
|
+
const CVE_ECOSYSTEM_FIELDS = /* @__PURE__ */ new Set([
|
|
1660
|
+
"cvss",
|
|
1661
|
+
"epss",
|
|
1662
|
+
"kev",
|
|
1663
|
+
"cwe",
|
|
1664
|
+
"affectedPackages"
|
|
1665
|
+
]);
|
|
1578
1666
|
/**
|
|
1579
1667
|
* Compute field-level changes for tracked fields between two requirements.
|
|
1580
1668
|
* Uses JSON Patch-like op/path format.
|
|
1669
|
+
*
|
|
1670
|
+
* Always runs the CVE-ecosystem handlers (cvss, epss, kev, cwe, affectedPackages)
|
|
1671
|
+
* in addition to whatever scalar fields are in trackedFields, since these
|
|
1672
|
+
* structured types need richer diff than generic JSON comparison provides.
|
|
1581
1673
|
*/
|
|
1582
1674
|
function computeFieldChanges(oldReq, newReq, trackedFields) {
|
|
1583
1675
|
const changes = [];
|
|
1584
1676
|
for (const field of trackedFields) {
|
|
1677
|
+
if (CVE_ECOSYSTEM_FIELDS.has(field)) continue;
|
|
1585
1678
|
const oldVal = oldReq[field];
|
|
1586
1679
|
const newVal = newReq[field];
|
|
1587
1680
|
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) if (oldVal === void 0 && newVal !== void 0) changes.push({
|
|
@@ -1601,6 +1694,367 @@ function computeFieldChanges(oldReq, newReq, trackedFields) {
|
|
|
1601
1694
|
newValue: newVal
|
|
1602
1695
|
});
|
|
1603
1696
|
}
|
|
1697
|
+
changes.push(...diffCvssArray(oldReq["cvss"], newReq["cvss"]));
|
|
1698
|
+
changes.push(...diffEpss(oldReq["epss"], newReq["epss"]));
|
|
1699
|
+
changes.push(...diffKev(oldReq["kev"], newReq["kev"]));
|
|
1700
|
+
changes.push(...diffCweSet(oldReq["cwe"], newReq["cwe"]));
|
|
1701
|
+
changes.push(...diffAffectedPackages(oldReq["affectedPackages"], newReq["affectedPackages"]));
|
|
1702
|
+
return changes;
|
|
1703
|
+
}
|
|
1704
|
+
const CVSS_VECTOR_FIELDS = [
|
|
1705
|
+
"baseVector",
|
|
1706
|
+
"threatVector",
|
|
1707
|
+
"environmentalVector",
|
|
1708
|
+
"supplementalVector"
|
|
1709
|
+
];
|
|
1710
|
+
/**
|
|
1711
|
+
* Parse a CVSS vector string into a map of metric-code → value.
|
|
1712
|
+
*
|
|
1713
|
+
* Accepts both v3.x ("CVSS:3.1/AV:N/AC:L/...") and v4.0 ("CVSS:4.0/AV:N/...")
|
|
1714
|
+
* shapes, plus partial vectors that omit the version prefix (e.g., the
|
|
1715
|
+
* `threatVector` field is conventionally just the threat metrics).
|
|
1716
|
+
*
|
|
1717
|
+
* Returns `null` for inputs that don't look like CVSS vectors so callers
|
|
1718
|
+
* can fall back to a scalar string compare.
|
|
1719
|
+
*
|
|
1720
|
+
* Delegates to `parseCvssVector` from `@mitre/hdf-utilities` but adapts the
|
|
1721
|
+
* return shape: returns null for empty/unparseable inputs so call sites can
|
|
1722
|
+
* fall back to scalar string compare.
|
|
1723
|
+
*/
|
|
1724
|
+
function parseCvssVector$1(vec) {
|
|
1725
|
+
if (typeof vec !== "string" || vec.length === 0) return null;
|
|
1726
|
+
const parsed = parseCvssVector(vec);
|
|
1727
|
+
if (parsed.metrics.size === 0) return null;
|
|
1728
|
+
return {
|
|
1729
|
+
version: parsed.version === "unknown" ? void 0 : parsed.version,
|
|
1730
|
+
metrics: parsed.metrics
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Diff a single pair of cvss entries (same source). Emits per-field changes
|
|
1735
|
+
* including decomposed per-metric vector diffs.
|
|
1736
|
+
*/
|
|
1737
|
+
function diffCvssEntry(source, oldEntry, newEntry) {
|
|
1738
|
+
const changes = [];
|
|
1739
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldEntry), ...Object.keys(newEntry)]);
|
|
1740
|
+
for (const key of allKeys) {
|
|
1741
|
+
if (key === "source") continue;
|
|
1742
|
+
const oldVal = oldEntry[key];
|
|
1743
|
+
const newVal = newEntry[key];
|
|
1744
|
+
if (JSON.stringify(oldVal) === JSON.stringify(newVal)) continue;
|
|
1745
|
+
if (CVSS_VECTOR_FIELDS.includes(key)) {
|
|
1746
|
+
if (typeof oldVal === "string" && typeof newVal === "string") {
|
|
1747
|
+
const oldParsed = parseCvssVector$1(oldVal);
|
|
1748
|
+
const newParsed = parseCvssVector$1(newVal);
|
|
1749
|
+
if (oldParsed && newParsed) {
|
|
1750
|
+
const metricKeys = /* @__PURE__ */ new Set([...oldParsed.metrics.keys(), ...newParsed.metrics.keys()]);
|
|
1751
|
+
for (const m of metricKeys) {
|
|
1752
|
+
const o = oldParsed.metrics.get(m);
|
|
1753
|
+
const n = newParsed.metrics.get(m);
|
|
1754
|
+
if (o === n) continue;
|
|
1755
|
+
if (o === void 0 && n !== void 0) changes.push({
|
|
1756
|
+
op: "add",
|
|
1757
|
+
path: `cvss[${source}].${key}.${m}`,
|
|
1758
|
+
newValue: n
|
|
1759
|
+
});
|
|
1760
|
+
else if (o !== void 0 && n === void 0) changes.push({
|
|
1761
|
+
op: "remove",
|
|
1762
|
+
path: `cvss[${source}].${key}.${m}`,
|
|
1763
|
+
oldValue: o
|
|
1764
|
+
});
|
|
1765
|
+
else changes.push({
|
|
1766
|
+
op: "replace",
|
|
1767
|
+
path: `cvss[${source}].${key}.${m}`,
|
|
1768
|
+
oldValue: o,
|
|
1769
|
+
newValue: n
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
} else if (oldVal === void 0 && typeof newVal === "string") {
|
|
1775
|
+
const parsed = parseCvssVector$1(newVal);
|
|
1776
|
+
if (parsed) {
|
|
1777
|
+
for (const [m, v] of parsed.metrics) changes.push({
|
|
1778
|
+
op: "add",
|
|
1779
|
+
path: `cvss[${source}].${key}.${m}`,
|
|
1780
|
+
newValue: v
|
|
1781
|
+
});
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
} else if (typeof oldVal === "string" && newVal === void 0) {
|
|
1785
|
+
const parsed = parseCvssVector$1(oldVal);
|
|
1786
|
+
if (parsed) {
|
|
1787
|
+
for (const [m, v] of parsed.metrics) changes.push({
|
|
1788
|
+
op: "remove",
|
|
1789
|
+
path: `cvss[${source}].${key}.${m}`,
|
|
1790
|
+
oldValue: v
|
|
1791
|
+
});
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (oldVal === void 0) changes.push({
|
|
1797
|
+
op: "add",
|
|
1798
|
+
path: `cvss[${source}].${key}`,
|
|
1799
|
+
newValue: newVal
|
|
1800
|
+
});
|
|
1801
|
+
else if (newVal === void 0) changes.push({
|
|
1802
|
+
op: "remove",
|
|
1803
|
+
path: `cvss[${source}].${key}`,
|
|
1804
|
+
oldValue: oldVal
|
|
1805
|
+
});
|
|
1806
|
+
else changes.push({
|
|
1807
|
+
op: "replace",
|
|
1808
|
+
path: `cvss[${source}].${key}`,
|
|
1809
|
+
oldValue: oldVal,
|
|
1810
|
+
newValue: newVal
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
return changes;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Diff old vs new cvss[] arrays. Entries are matched by `source` (CVE ID
|
|
1817
|
+
* or scoring authority); added/removed entries emit whole-entry add/remove
|
|
1818
|
+
* records; matched pairs are decomposed via diffCvssEntry.
|
|
1819
|
+
*/
|
|
1820
|
+
function diffCvssArray(oldField, newField) {
|
|
1821
|
+
const oldArr = Array.isArray(oldField) ? oldField : [];
|
|
1822
|
+
const newArr = Array.isArray(newField) ? newField : [];
|
|
1823
|
+
if (oldArr.length === 0 && newArr.length === 0) return [];
|
|
1824
|
+
const oldBySource = /* @__PURE__ */ new Map();
|
|
1825
|
+
for (const e of oldArr) if (typeof e?.source === "string") oldBySource.set(e.source, e);
|
|
1826
|
+
const newBySource = /* @__PURE__ */ new Map();
|
|
1827
|
+
for (const e of newArr) if (typeof e?.source === "string") newBySource.set(e.source, e);
|
|
1828
|
+
const changes = [];
|
|
1829
|
+
const allSources = /* @__PURE__ */ new Set([...oldBySource.keys(), ...newBySource.keys()]);
|
|
1830
|
+
for (const source of allSources) {
|
|
1831
|
+
const o = oldBySource.get(source);
|
|
1832
|
+
const n = newBySource.get(source);
|
|
1833
|
+
if (o && !n) changes.push({
|
|
1834
|
+
op: "remove",
|
|
1835
|
+
path: `cvss[${source}]`,
|
|
1836
|
+
oldValue: o
|
|
1837
|
+
});
|
|
1838
|
+
else if (!o && n) changes.push({
|
|
1839
|
+
op: "add",
|
|
1840
|
+
path: `cvss[${source}]`,
|
|
1841
|
+
newValue: n
|
|
1842
|
+
});
|
|
1843
|
+
else if (o && n) changes.push(...diffCvssEntry(source, o, n));
|
|
1844
|
+
}
|
|
1845
|
+
return changes;
|
|
1846
|
+
}
|
|
1847
|
+
/** Diff the optional `epss` object (score, percentile, date). */
|
|
1848
|
+
function diffEpss(oldField, newField) {
|
|
1849
|
+
if (oldField === void 0 && newField === void 0) return [];
|
|
1850
|
+
if (oldField === void 0) return [{
|
|
1851
|
+
op: "add",
|
|
1852
|
+
path: "epss",
|
|
1853
|
+
newValue: newField
|
|
1854
|
+
}];
|
|
1855
|
+
if (newField === void 0) return [{
|
|
1856
|
+
op: "remove",
|
|
1857
|
+
path: "epss",
|
|
1858
|
+
oldValue: oldField
|
|
1859
|
+
}];
|
|
1860
|
+
if (typeof oldField !== "object" || typeof newField !== "object" || oldField === null || newField === null) {
|
|
1861
|
+
if (JSON.stringify(oldField) === JSON.stringify(newField)) return [];
|
|
1862
|
+
return [{
|
|
1863
|
+
op: "replace",
|
|
1864
|
+
path: "epss",
|
|
1865
|
+
oldValue: oldField,
|
|
1866
|
+
newValue: newField
|
|
1867
|
+
}];
|
|
1868
|
+
}
|
|
1869
|
+
const oldObj = oldField;
|
|
1870
|
+
const newObj = newField;
|
|
1871
|
+
const changes = [];
|
|
1872
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
1873
|
+
for (const key of allKeys) {
|
|
1874
|
+
const o = oldObj[key];
|
|
1875
|
+
const n = newObj[key];
|
|
1876
|
+
if (JSON.stringify(o) === JSON.stringify(n)) continue;
|
|
1877
|
+
if (o === void 0) changes.push({
|
|
1878
|
+
op: "add",
|
|
1879
|
+
path: `epss.${key}`,
|
|
1880
|
+
newValue: n
|
|
1881
|
+
});
|
|
1882
|
+
else if (n === void 0) changes.push({
|
|
1883
|
+
op: "remove",
|
|
1884
|
+
path: `epss.${key}`,
|
|
1885
|
+
oldValue: o
|
|
1886
|
+
});
|
|
1887
|
+
else changes.push({
|
|
1888
|
+
op: "replace",
|
|
1889
|
+
path: `epss.${key}`,
|
|
1890
|
+
oldValue: o,
|
|
1891
|
+
newValue: n
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
return changes;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Diff the optional `kev` object. Flips of `inKev` from false→true (or
|
|
1898
|
+
* fresh add with `inKev: true`) get an annotated `message` field calling
|
|
1899
|
+
* out the CISA KEV catalog inclusion.
|
|
1900
|
+
*/
|
|
1901
|
+
function diffKev(oldField, newField) {
|
|
1902
|
+
if (oldField === void 0 && newField === void 0) return [];
|
|
1903
|
+
if (oldField === void 0) {
|
|
1904
|
+
const change = {
|
|
1905
|
+
op: "add",
|
|
1906
|
+
path: "kev",
|
|
1907
|
+
newValue: newField
|
|
1908
|
+
};
|
|
1909
|
+
const nObj = newField;
|
|
1910
|
+
if (nObj && nObj["inKev"] === true) change.message = `Newly added to CISA KEV catalog as of ${typeof nObj["dateAdded"] === "string" ? nObj["dateAdded"] : "unknown date"}`;
|
|
1911
|
+
return [change];
|
|
1912
|
+
}
|
|
1913
|
+
if (newField === void 0) return [{
|
|
1914
|
+
op: "remove",
|
|
1915
|
+
path: "kev",
|
|
1916
|
+
oldValue: oldField
|
|
1917
|
+
}];
|
|
1918
|
+
if (typeof oldField !== "object" || typeof newField !== "object" || oldField === null || newField === null) {
|
|
1919
|
+
if (JSON.stringify(oldField) === JSON.stringify(newField)) return [];
|
|
1920
|
+
return [{
|
|
1921
|
+
op: "replace",
|
|
1922
|
+
path: "kev",
|
|
1923
|
+
oldValue: oldField,
|
|
1924
|
+
newValue: newField
|
|
1925
|
+
}];
|
|
1926
|
+
}
|
|
1927
|
+
const oldObj = oldField;
|
|
1928
|
+
const newObj = newField;
|
|
1929
|
+
const changes = [];
|
|
1930
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
|
1931
|
+
for (const key of allKeys) {
|
|
1932
|
+
const o = oldObj[key];
|
|
1933
|
+
const n = newObj[key];
|
|
1934
|
+
if (JSON.stringify(o) === JSON.stringify(n)) continue;
|
|
1935
|
+
let change;
|
|
1936
|
+
if (o === void 0) change = {
|
|
1937
|
+
op: "add",
|
|
1938
|
+
path: `kev.${key}`,
|
|
1939
|
+
newValue: n
|
|
1940
|
+
};
|
|
1941
|
+
else if (n === void 0) change = {
|
|
1942
|
+
op: "remove",
|
|
1943
|
+
path: `kev.${key}`,
|
|
1944
|
+
oldValue: o
|
|
1945
|
+
};
|
|
1946
|
+
else change = {
|
|
1947
|
+
op: "replace",
|
|
1948
|
+
path: `kev.${key}`,
|
|
1949
|
+
oldValue: o,
|
|
1950
|
+
newValue: n
|
|
1951
|
+
};
|
|
1952
|
+
if (key === "inKev" && o === false && n === true) {
|
|
1953
|
+
const dateAdded = typeof newObj["dateAdded"] === "string" ? newObj["dateAdded"] : "unknown date";
|
|
1954
|
+
change.message = `Newly added to CISA KEV catalog as of ${dateAdded}`;
|
|
1955
|
+
}
|
|
1956
|
+
changes.push(change);
|
|
1957
|
+
}
|
|
1958
|
+
return changes;
|
|
1959
|
+
}
|
|
1960
|
+
/** Diff the `cwe[]` field as a set — order does not matter. */
|
|
1961
|
+
function diffCweSet(oldField, newField) {
|
|
1962
|
+
const toSet = (v) => {
|
|
1963
|
+
if (!Array.isArray(v)) return /* @__PURE__ */ new Set();
|
|
1964
|
+
return new Set(v.filter((x) => typeof x === "string"));
|
|
1965
|
+
};
|
|
1966
|
+
const oldSet = toSet(oldField);
|
|
1967
|
+
const newSet = toSet(newField);
|
|
1968
|
+
if (oldSet.size === 0 && newSet.size === 0) return [];
|
|
1969
|
+
const changes = [];
|
|
1970
|
+
for (const c of newSet) if (!oldSet.has(c)) changes.push({
|
|
1971
|
+
op: "add",
|
|
1972
|
+
path: "cwe",
|
|
1973
|
+
newValue: c
|
|
1974
|
+
});
|
|
1975
|
+
for (const c of oldSet) if (!newSet.has(c)) changes.push({
|
|
1976
|
+
op: "remove",
|
|
1977
|
+
path: "cwe",
|
|
1978
|
+
oldValue: c
|
|
1979
|
+
});
|
|
1980
|
+
return changes;
|
|
1981
|
+
}
|
|
1982
|
+
/** Build the natural key for an AffectedPackage: `name@version`. */
|
|
1983
|
+
function packageKey(pkg) {
|
|
1984
|
+
const name = pkg?.name;
|
|
1985
|
+
const version = pkg?.version;
|
|
1986
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
1987
|
+
if (typeof version !== "string" || version.length === 0) return null;
|
|
1988
|
+
return `${name}@${version}`;
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* Diff a single pair of affectedPackages entries (matched by name+version).
|
|
1992
|
+
* Emits per-field changes for cpe / purl / fixedInVersion and any other
|
|
1993
|
+
* non-key fields that differ.
|
|
1994
|
+
*/
|
|
1995
|
+
function diffAffectedPackageEntry(key, oldPkg, newPkg) {
|
|
1996
|
+
const changes = [];
|
|
1997
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldPkg), ...Object.keys(newPkg)]);
|
|
1998
|
+
for (const k of allKeys) {
|
|
1999
|
+
if (k === "name" || k === "version") continue;
|
|
2000
|
+
const o = oldPkg[k];
|
|
2001
|
+
const n = newPkg[k];
|
|
2002
|
+
if (JSON.stringify(o) === JSON.stringify(n)) continue;
|
|
2003
|
+
if (o === void 0) changes.push({
|
|
2004
|
+
op: "add",
|
|
2005
|
+
path: `affectedPackages[${key}].${k}`,
|
|
2006
|
+
newValue: n
|
|
2007
|
+
});
|
|
2008
|
+
else if (n === void 0) changes.push({
|
|
2009
|
+
op: "remove",
|
|
2010
|
+
path: `affectedPackages[${key}].${k}`,
|
|
2011
|
+
oldValue: o
|
|
2012
|
+
});
|
|
2013
|
+
else changes.push({
|
|
2014
|
+
op: "replace",
|
|
2015
|
+
path: `affectedPackages[${key}].${k}`,
|
|
2016
|
+
oldValue: o,
|
|
2017
|
+
newValue: n
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
return changes;
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Diff old vs new affectedPackages[] arrays. Entries are matched by the
|
|
2024
|
+
* `name@version` tuple; added/removed packages emit whole-entry records;
|
|
2025
|
+
* matched pairs are decomposed via diffAffectedPackageEntry.
|
|
2026
|
+
*/
|
|
2027
|
+
function diffAffectedPackages(oldField, newField) {
|
|
2028
|
+
const oldArr = Array.isArray(oldField) ? oldField : [];
|
|
2029
|
+
const newArr = Array.isArray(newField) ? newField : [];
|
|
2030
|
+
if (oldArr.length === 0 && newArr.length === 0) return [];
|
|
2031
|
+
const oldByKey = /* @__PURE__ */ new Map();
|
|
2032
|
+
for (const p of oldArr) {
|
|
2033
|
+
const k = packageKey(p);
|
|
2034
|
+
if (k) oldByKey.set(k, p);
|
|
2035
|
+
}
|
|
2036
|
+
const newByKey = /* @__PURE__ */ new Map();
|
|
2037
|
+
for (const p of newArr) {
|
|
2038
|
+
const k = packageKey(p);
|
|
2039
|
+
if (k) newByKey.set(k, p);
|
|
2040
|
+
}
|
|
2041
|
+
const changes = [];
|
|
2042
|
+
const allKeys = /* @__PURE__ */ new Set([...oldByKey.keys(), ...newByKey.keys()]);
|
|
2043
|
+
for (const key of allKeys) {
|
|
2044
|
+
const o = oldByKey.get(key);
|
|
2045
|
+
const n = newByKey.get(key);
|
|
2046
|
+
if (o && !n) changes.push({
|
|
2047
|
+
op: "remove",
|
|
2048
|
+
path: `affectedPackages[${key}]`,
|
|
2049
|
+
oldValue: o
|
|
2050
|
+
});
|
|
2051
|
+
else if (!o && n) changes.push({
|
|
2052
|
+
op: "add",
|
|
2053
|
+
path: `affectedPackages[${key}]`,
|
|
2054
|
+
newValue: n
|
|
2055
|
+
});
|
|
2056
|
+
else if (o && n) changes.push(...diffAffectedPackageEntry(key, o, n));
|
|
2057
|
+
}
|
|
1604
2058
|
return changes;
|
|
1605
2059
|
}
|
|
1606
2060
|
/**
|
|
@@ -1915,7 +2369,7 @@ function diffDataFlows(oldSystem, newSystem) {
|
|
|
1915
2369
|
for (const f of oldFlows) oldMap.set(flowKey(f), f);
|
|
1916
2370
|
const newMap = /* @__PURE__ */ new Map();
|
|
1917
2371
|
for (const f of newFlows) newMap.set(flowKey(f), f);
|
|
1918
|
-
const allKeys = new Set([...oldMap.keys(), ...newMap.keys()]);
|
|
2372
|
+
const allKeys = /* @__PURE__ */ new Set([...oldMap.keys(), ...newMap.keys()]);
|
|
1919
2373
|
const changes = [];
|
|
1920
2374
|
for (const key of allKeys) {
|
|
1921
2375
|
const oldF = oldMap.get(key);
|
|
@@ -2077,7 +2531,7 @@ function stripSnapshots(req) {
|
|
|
2077
2531
|
return rest;
|
|
2078
2532
|
}
|
|
2079
2533
|
/**
|
|
2080
|
-
* Render an
|
|
2534
|
+
* Render an HDFComparison as a JSON string.
|
|
2081
2535
|
*
|
|
2082
2536
|
* - `detail: 'summary'` -- only `{ formatVersion, comparisonMode, summary }`
|
|
2083
2537
|
* - `detail: 'control'` -- full document but `before`/`after` stripped from requirementDiffs
|
|
@@ -2191,7 +2645,7 @@ function renderStateSection(state, diffs, detail) {
|
|
|
2191
2645
|
return lines.join("\n");
|
|
2192
2646
|
}
|
|
2193
2647
|
/**
|
|
2194
|
-
* Render an
|
|
2648
|
+
* Render an HDFComparison as a Markdown string.
|
|
2195
2649
|
*
|
|
2196
2650
|
* - `detail: 'summary'` -- summary table only
|
|
2197
2651
|
* - `detail: 'control'` -- summary + per-requirement tables by state
|
|
@@ -2327,7 +2781,7 @@ function buildHeaderLine(comparison, useColor) {
|
|
|
2327
2781
|
return header;
|
|
2328
2782
|
}
|
|
2329
2783
|
/**
|
|
2330
|
-
* Render an
|
|
2784
|
+
* Render an HDFComparison for terminal display with optional ANSI colors.
|
|
2331
2785
|
*
|
|
2332
2786
|
* - `detail: 'summary'` -- just the summary line
|
|
2333
2787
|
* - `detail: 'control'` -- requirement list + summary (excludes unchanged)
|
|
@@ -2380,7 +2834,7 @@ function formatFieldChanges(req) {
|
|
|
2380
2834
|
}).join("; ");
|
|
2381
2835
|
}
|
|
2382
2836
|
/**
|
|
2383
|
-
* Render an
|
|
2837
|
+
* Render an HDFComparison as a CSV string.
|
|
2384
2838
|
*
|
|
2385
2839
|
* One row per requirement. Columns:
|
|
2386
2840
|
* - ID, Title, State, Old Status, New Status, Impact (Old), Impact (New), Change Reasons
|
|
@@ -2426,7 +2880,7 @@ function renderCsv(comparison, options) {
|
|
|
2426
2880
|
/**
|
|
2427
2881
|
* Convenience function to render a comparison in any supported format.
|
|
2428
2882
|
*
|
|
2429
|
-
* @param comparison - The
|
|
2883
|
+
* @param comparison - The HDFComparison document to render
|
|
2430
2884
|
* @param format - Output format: 'json', 'markdown', 'terminal', or 'csv'
|
|
2431
2885
|
* @param options - Rendering options (detail level, filters, color)
|
|
2432
2886
|
* @returns The rendered string
|