@mitre/hdf-diff 3.1.0 → 3.3.0
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 +93 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +906 -45
- 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 = [
|
|
@@ -398,7 +399,7 @@ function createMappedIdStrategy(mapping) {
|
|
|
398
399
|
* Extract CCI identifiers from a requirement's tags.
|
|
399
400
|
* Looks for `tags.cci` as an array of strings.
|
|
400
401
|
*/
|
|
401
|
-
function extractCcis(req) {
|
|
402
|
+
function extractCcis$1(req) {
|
|
402
403
|
const tags = req["tags"];
|
|
403
404
|
if (!tags) return [];
|
|
404
405
|
const cci = tags["cci"];
|
|
@@ -406,6 +407,65 @@ function extractCcis(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 = 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,51 +474,44 @@ function extractCcis(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(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(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
|
}
|
|
@@ -648,6 +701,437 @@ function createFuzzyTitleStrategy(minConfidence) {
|
|
|
648
701
|
};
|
|
649
702
|
}
|
|
650
703
|
//#endregion
|
|
704
|
+
//#region src/matching/srg-deterministic.ts
|
|
705
|
+
/**
|
|
706
|
+
* Extract the SRG-OS ID from a requirement's tags.gtitle field.
|
|
707
|
+
* Returns null if missing or not a string.
|
|
708
|
+
*/
|
|
709
|
+
function extractGtitle$1(req) {
|
|
710
|
+
const tags = req["tags"];
|
|
711
|
+
if (!tags) return null;
|
|
712
|
+
const gtitle = tags["gtitle"];
|
|
713
|
+
if (typeof gtitle !== "string" || gtitle === "") return null;
|
|
714
|
+
return gtitle;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Create a deterministic SRG matching strategy (Tier 1 of the delta pipeline).
|
|
718
|
+
*
|
|
719
|
+
* Matches requirements by exact `tags.gtitle` (SRG-OS requirement ID).
|
|
720
|
+
*
|
|
721
|
+
* For each gtitle shared between old and new:
|
|
722
|
+
* - 1 old + 1 new → single MatchPair (confidence 1.0, relationship "primary")
|
|
723
|
+
* - 1 new + N old → N MatchPairs (first old "primary", rest "related")
|
|
724
|
+
* - N new + 1 old → N MatchPairs (first new "primary", rest "related")
|
|
725
|
+
* - N:M (both > 1) → skip, leave for fallback strategies
|
|
726
|
+
*
|
|
727
|
+
* Requirements without a gtitle pass through as unmatched.
|
|
728
|
+
*/
|
|
729
|
+
function createSrgDeterministicStrategy() {
|
|
730
|
+
return {
|
|
731
|
+
name: "srgDeterministic",
|
|
732
|
+
match(oldReqs, newReqs) {
|
|
733
|
+
const oldGtitleMap = /* @__PURE__ */ new Map();
|
|
734
|
+
for (let i = 0; i < oldReqs.length; i++) {
|
|
735
|
+
const g = extractGtitle$1(oldReqs[i]);
|
|
736
|
+
if (g) {
|
|
737
|
+
const list = oldGtitleMap.get(g);
|
|
738
|
+
if (list) list.push(i);
|
|
739
|
+
else oldGtitleMap.set(g, [i]);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const newGtitleMap = /* @__PURE__ */ new Map();
|
|
743
|
+
for (let i = 0; i < newReqs.length; i++) {
|
|
744
|
+
const g = extractGtitle$1(newReqs[i]);
|
|
745
|
+
if (g) {
|
|
746
|
+
const list = newGtitleMap.get(g);
|
|
747
|
+
if (list) list.push(i);
|
|
748
|
+
else newGtitleMap.set(g, [i]);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const matched = [];
|
|
752
|
+
const matchedOldIndices = /* @__PURE__ */ new Set();
|
|
753
|
+
const matchedNewIndices = /* @__PURE__ */ new Set();
|
|
754
|
+
const claimedOldIndices = /* @__PURE__ */ new Set();
|
|
755
|
+
for (const [gtitle, newIdxList] of newGtitleMap) {
|
|
756
|
+
const oldIdxList = oldGtitleMap.get(gtitle);
|
|
757
|
+
if (!oldIdxList || oldIdxList.length === 0) continue;
|
|
758
|
+
const nc = newIdxList.length;
|
|
759
|
+
const oc = oldIdxList.length;
|
|
760
|
+
if (nc > 1 && oc > 1) continue;
|
|
761
|
+
for (const ni of newIdxList) {
|
|
762
|
+
let primaryOldSet = false;
|
|
763
|
+
for (const oi of oldIdxList) {
|
|
764
|
+
let relationship;
|
|
765
|
+
if (oc === 1) {
|
|
766
|
+
relationship = claimedOldIndices.has(oi) ? "related" : "primary";
|
|
767
|
+
if (relationship === "primary") claimedOldIndices.add(oi);
|
|
768
|
+
} else {
|
|
769
|
+
relationship = primaryOldSet ? "related" : "primary";
|
|
770
|
+
primaryOldSet = true;
|
|
771
|
+
}
|
|
772
|
+
matched.push({
|
|
773
|
+
oldReq: oldReqs[oi],
|
|
774
|
+
newReq: newReqs[ni],
|
|
775
|
+
strategy: "srgDeterministic",
|
|
776
|
+
confidence: 1,
|
|
777
|
+
relationship
|
|
778
|
+
});
|
|
779
|
+
matchedOldIndices.add(oi);
|
|
780
|
+
}
|
|
781
|
+
matchedNewIndices.add(ni);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const unmatchedOld = [];
|
|
785
|
+
for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) unmatchedOld.push(oldReqs[i]);
|
|
786
|
+
const unmatchedNew = [];
|
|
787
|
+
for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) unmatchedNew.push(newReqs[i]);
|
|
788
|
+
return {
|
|
789
|
+
matched,
|
|
790
|
+
unmatchedOld,
|
|
791
|
+
unmatchedNew
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/matching/srg-cci-tiebreak.ts
|
|
798
|
+
/** Composite score weights matching SAF CLI's Tier 2. */
|
|
799
|
+
const CCI_WEIGHT = .7;
|
|
800
|
+
const TITLE_WEIGHT = .3;
|
|
801
|
+
/**
|
|
802
|
+
* Extract CCI identifiers from a requirement's tags.cci.
|
|
803
|
+
*/
|
|
804
|
+
function extractCcis(req) {
|
|
805
|
+
const tags = req["tags"];
|
|
806
|
+
if (!tags) return /* @__PURE__ */ new Set();
|
|
807
|
+
const cci = tags["cci"];
|
|
808
|
+
if (!Array.isArray(cci)) return /* @__PURE__ */ new Set();
|
|
809
|
+
return new Set(cci.filter((c) => typeof c === "string"));
|
|
810
|
+
}
|
|
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 new Set([tagCwe]);
|
|
831
|
+
return /* @__PURE__ */ new Set();
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Extract tags.gtitle from a requirement.
|
|
835
|
+
*/
|
|
836
|
+
function extractGtitle(req) {
|
|
837
|
+
const tags = req["tags"];
|
|
838
|
+
if (!tags) return null;
|
|
839
|
+
const gtitle = tags["gtitle"];
|
|
840
|
+
if (typeof gtitle !== "string" || gtitle === "") return null;
|
|
841
|
+
return gtitle;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Extract title from a requirement.
|
|
845
|
+
*/
|
|
846
|
+
function safeTitle$1(req) {
|
|
847
|
+
const title = req["title"];
|
|
848
|
+
return typeof title === "string" ? title : "";
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Jaccard similarity between two sets.
|
|
852
|
+
*/
|
|
853
|
+
function cciJaccard(a, b) {
|
|
854
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
855
|
+
let intersectionSize = 0;
|
|
856
|
+
for (const x of a) if (b.has(x)) intersectionSize++;
|
|
857
|
+
const unionSize = a.size + b.size - intersectionSize;
|
|
858
|
+
return intersectionSize / unionSize;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Simple whitespace-split token Jaccard similarity.
|
|
862
|
+
*/
|
|
863
|
+
function tokenJaccard(a, b) {
|
|
864
|
+
const ta = new Set(a.toLowerCase().split(/\s+/).filter((t) => t.length > 0));
|
|
865
|
+
const tb = new Set(b.toLowerCase().split(/\s+/).filter((t) => t.length > 0));
|
|
866
|
+
if (ta.size === 0 || tb.size === 0) return 0;
|
|
867
|
+
let intersectionSize = 0;
|
|
868
|
+
for (const x of ta) if (tb.has(x)) intersectionSize++;
|
|
869
|
+
const unionSize = ta.size + tb.size - intersectionSize;
|
|
870
|
+
return intersectionSize / unionSize;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Create an SRG CCI Tiebreak matching strategy (Tier 2 of the delta pipeline).
|
|
874
|
+
*
|
|
875
|
+
* Handles ambiguous SRG matches where multiple old or new requirements share
|
|
876
|
+
* the same tags.gtitle. Uses a composite score of CCI Jaccard (70%) and
|
|
877
|
+
* token Jaccard on titles (30%) to pick the best match.
|
|
878
|
+
*
|
|
879
|
+
* Only activates for gtitle groups with multiple candidates (>1 old or >1 new).
|
|
880
|
+
* 1:1 matches are left for srgDeterministic (Tier 1).
|
|
881
|
+
*/
|
|
882
|
+
function createSrgCciTiebreakStrategy() {
|
|
883
|
+
return {
|
|
884
|
+
name: "srgCciTiebreak",
|
|
885
|
+
match(oldReqs, newReqs) {
|
|
886
|
+
const oldGtitleMap = /* @__PURE__ */ new Map();
|
|
887
|
+
for (let i = 0; i < oldReqs.length; i++) {
|
|
888
|
+
const g = extractGtitle(oldReqs[i]);
|
|
889
|
+
if (g) {
|
|
890
|
+
const list = oldGtitleMap.get(g);
|
|
891
|
+
if (list) list.push(i);
|
|
892
|
+
else oldGtitleMap.set(g, [i]);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const newGtitleMap = /* @__PURE__ */ new Map();
|
|
896
|
+
for (let i = 0; i < newReqs.length; i++) {
|
|
897
|
+
const g = extractGtitle(newReqs[i]);
|
|
898
|
+
if (g) {
|
|
899
|
+
const list = newGtitleMap.get(g);
|
|
900
|
+
if (list) list.push(i);
|
|
901
|
+
else newGtitleMap.set(g, [i]);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const matched = [];
|
|
905
|
+
const matchedOldIndices = /* @__PURE__ */ new Set();
|
|
906
|
+
const matchedNewIndices = /* @__PURE__ */ new Set();
|
|
907
|
+
for (const [gtitle, newIdxList] of newGtitleMap) {
|
|
908
|
+
const oldIdxList = oldGtitleMap.get(gtitle);
|
|
909
|
+
if (!oldIdxList || oldIdxList.length === 0) continue;
|
|
910
|
+
const nc = newIdxList.length;
|
|
911
|
+
const oc = oldIdxList.length;
|
|
912
|
+
if (nc === 1 && oc === 1) continue;
|
|
913
|
+
for (const ni of newIdxList) {
|
|
914
|
+
if (matchedNewIndices.has(ni)) continue;
|
|
915
|
+
const newCcis = extractCcis(newReqs[ni]);
|
|
916
|
+
const newTitle = safeTitle$1(newReqs[ni]);
|
|
917
|
+
const newCwes = extractCwes(newReqs[ni]);
|
|
918
|
+
let bestIdx = -1;
|
|
919
|
+
let bestComposite = -1;
|
|
920
|
+
let bestCci = 0;
|
|
921
|
+
let bestCwe = -1;
|
|
922
|
+
let bestIsUnclaimed = false;
|
|
923
|
+
for (const oi of oldIdxList) {
|
|
924
|
+
const oldCcis = extractCcis(oldReqs[oi]);
|
|
925
|
+
const oldTitle = safeTitle$1(oldReqs[oi]);
|
|
926
|
+
const oldCwes = extractCwes(oldReqs[oi]);
|
|
927
|
+
const cci = cciJaccard(newCcis, oldCcis);
|
|
928
|
+
const title = tokenJaccard(newTitle, oldTitle);
|
|
929
|
+
const cwe = cciJaccard(newCwes, oldCwes);
|
|
930
|
+
const composite = CCI_WEIGHT * cci + TITLE_WEIGHT * title;
|
|
931
|
+
const isUnclaimed = !matchedOldIndices.has(oi);
|
|
932
|
+
if (bestIdx === -1 || isUnclaimed && !bestIsUnclaimed || isUnclaimed === bestIsUnclaimed && composite > bestComposite || isUnclaimed === bestIsUnclaimed && composite === bestComposite && cwe > bestCwe) {
|
|
933
|
+
bestIdx = oi;
|
|
934
|
+
bestComposite = composite;
|
|
935
|
+
bestCci = cci;
|
|
936
|
+
bestCwe = cwe;
|
|
937
|
+
bestIsUnclaimed = isUnclaimed;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (bestIdx >= 0) {
|
|
941
|
+
const relationship = matchedOldIndices.has(bestIdx) ? "related" : "primary";
|
|
942
|
+
matched.push({
|
|
943
|
+
oldReq: oldReqs[bestIdx],
|
|
944
|
+
newReq: newReqs[ni],
|
|
945
|
+
strategy: "srgCciTiebreak",
|
|
946
|
+
confidence: Math.max(bestCci, 0),
|
|
947
|
+
relationship
|
|
948
|
+
});
|
|
949
|
+
matchedOldIndices.add(bestIdx);
|
|
950
|
+
matchedNewIndices.add(ni);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const unmatchedOld = [];
|
|
955
|
+
for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) unmatchedOld.push(oldReqs[i]);
|
|
956
|
+
const unmatchedNew = [];
|
|
957
|
+
for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) unmatchedNew.push(newReqs[i]);
|
|
958
|
+
return {
|
|
959
|
+
matched,
|
|
960
|
+
unmatchedOld,
|
|
961
|
+
unmatchedNew
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
//#endregion
|
|
967
|
+
//#region src/matching/vendor-fuzzy-title.ts
|
|
968
|
+
/** Default threshold for accepting a Levenshtein match. */
|
|
969
|
+
const DEFAULT_ACCEPT_THRESHOLD = .45;
|
|
970
|
+
/** Modal verbs that mark the start of compliance statements. */
|
|
971
|
+
const COMPLIANCE_MODALS = new Set([
|
|
972
|
+
"must",
|
|
973
|
+
"will",
|
|
974
|
+
"shall",
|
|
975
|
+
"should",
|
|
976
|
+
"may",
|
|
977
|
+
"needs"
|
|
978
|
+
]);
|
|
979
|
+
/**
|
|
980
|
+
* Compute the Levenshtein edit distance between two strings.
|
|
981
|
+
*/
|
|
982
|
+
function levenshteinDistance(a, b) {
|
|
983
|
+
const m = a.length;
|
|
984
|
+
const n = b.length;
|
|
985
|
+
if (m === 0) return n;
|
|
986
|
+
if (n === 0) return m;
|
|
987
|
+
let prev = new Array(n + 1);
|
|
988
|
+
let curr = new Array(n + 1);
|
|
989
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
990
|
+
for (let i = 1; i <= m; i++) {
|
|
991
|
+
curr[0] = i;
|
|
992
|
+
for (let j = 1; j <= n; j++) {
|
|
993
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
994
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
995
|
+
}
|
|
996
|
+
[prev, curr] = [curr, prev];
|
|
997
|
+
}
|
|
998
|
+
return prev[n];
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Normalized Levenshtein distance (0.0 = identical, 1.0 = completely different).
|
|
1002
|
+
*/
|
|
1003
|
+
function normalizedLevenshtein(a, b) {
|
|
1004
|
+
const maxLen = Math.max(a.length, b.length);
|
|
1005
|
+
if (maxLen === 0) return 0;
|
|
1006
|
+
return levenshteinDistance(a, b) / maxLen;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Extract tokens before the first modal verb in a title.
|
|
1010
|
+
*/
|
|
1011
|
+
function tokensBeforeModal(title) {
|
|
1012
|
+
const tokens = title.split(/\s+/).filter((t) => t.length > 0);
|
|
1013
|
+
const modalIdx = tokens.findIndex((t) => COMPLIANCE_MODALS.has(t.toLowerCase()));
|
|
1014
|
+
return modalIdx === -1 ? tokens : tokens.slice(0, modalIdx);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Auto-detect the dominant vendor prefix in a corpus of titles.
|
|
1018
|
+
*
|
|
1019
|
+
* Tries progressively shorter leading-token prefixes. Returns the prefix
|
|
1020
|
+
* that appears in > 50% of titles, stopping before modal verbs.
|
|
1021
|
+
* Returns '' when no prefix reaches the threshold.
|
|
1022
|
+
*/
|
|
1023
|
+
function autoDetectPrefix(titles, threshold = .5) {
|
|
1024
|
+
if (titles.length === 0) return "";
|
|
1025
|
+
const leading = titles.map((t) => tokensBeforeModal(t));
|
|
1026
|
+
const maxLen = Math.max(...leading.map((l) => l.length));
|
|
1027
|
+
for (let n = maxLen; n > 0; n--) {
|
|
1028
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1029
|
+
for (const l of leading) if (l.length >= n) {
|
|
1030
|
+
const key = l.slice(0, n).join(" ");
|
|
1031
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1032
|
+
}
|
|
1033
|
+
let bestKey = "";
|
|
1034
|
+
let bestCount = 0;
|
|
1035
|
+
for (const [key, count] of counts) if (count > bestCount) {
|
|
1036
|
+
bestKey = key;
|
|
1037
|
+
bestCount = count;
|
|
1038
|
+
}
|
|
1039
|
+
if (bestCount / titles.length > threshold) return bestKey;
|
|
1040
|
+
}
|
|
1041
|
+
return "";
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Strip a detected vendor prefix from a title.
|
|
1045
|
+
*/
|
|
1046
|
+
function normalizeTitle(title, prefix) {
|
|
1047
|
+
if (!prefix) return title;
|
|
1048
|
+
if (title === prefix) return "";
|
|
1049
|
+
if (title.startsWith(prefix + " ")) return title.slice(prefix.length + 1);
|
|
1050
|
+
return title;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Extract title string from a requirement.
|
|
1054
|
+
*/
|
|
1055
|
+
function safeTitle(req) {
|
|
1056
|
+
const title = req["title"];
|
|
1057
|
+
return typeof title === "string" ? title : "";
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Create a vendor fuzzy title matching strategy (Tier 3 of the delta pipeline).
|
|
1061
|
+
*
|
|
1062
|
+
* Cross-vendor matching with auto-detected vendor prefix stripping.
|
|
1063
|
+
* Uses normalized Levenshtein distance to compare titles after removing
|
|
1064
|
+
* the dominant vendor prefix (e.g., "RHEL 9" or "Amazon Linux 2023").
|
|
1065
|
+
*
|
|
1066
|
+
* Accepts matches below threshold 0.45 (confidence = 1.0 - distance).
|
|
1067
|
+
* Greedy best-match.
|
|
1068
|
+
*
|
|
1069
|
+
* @param acceptThreshold Max normalized Levenshtein distance to accept (default: 0.45)
|
|
1070
|
+
*/
|
|
1071
|
+
function createVendorFuzzyTitleStrategy(acceptThreshold) {
|
|
1072
|
+
const threshold = acceptThreshold ?? DEFAULT_ACCEPT_THRESHOLD;
|
|
1073
|
+
return {
|
|
1074
|
+
name: "vendorFuzzyTitle",
|
|
1075
|
+
match(oldReqs, newReqs) {
|
|
1076
|
+
const oldTitles = oldReqs.map(safeTitle).filter((t) => t.length > 0);
|
|
1077
|
+
const newTitles = newReqs.map(safeTitle).filter((t) => t.length > 0);
|
|
1078
|
+
const oldPrefix = autoDetectPrefix(oldTitles);
|
|
1079
|
+
const newPrefix = autoDetectPrefix(newTitles);
|
|
1080
|
+
const oldNorm = [];
|
|
1081
|
+
for (let i = 0; i < oldReqs.length; i++) {
|
|
1082
|
+
const t = safeTitle(oldReqs[i]);
|
|
1083
|
+
if (t) oldNorm.push({
|
|
1084
|
+
idx: i,
|
|
1085
|
+
normalized: normalizeTitle(t, oldPrefix)
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
const newNorm = [];
|
|
1089
|
+
for (let i = 0; i < newReqs.length; i++) {
|
|
1090
|
+
const t = safeTitle(newReqs[i]);
|
|
1091
|
+
if (t) newNorm.push({
|
|
1092
|
+
idx: i,
|
|
1093
|
+
normalized: normalizeTitle(t, newPrefix)
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
const candidates = [];
|
|
1097
|
+
for (const old of oldNorm) for (const nw of newNorm) {
|
|
1098
|
+
if (!old.normalized || !nw.normalized) continue;
|
|
1099
|
+
const dist = normalizedLevenshtein(old.normalized, nw.normalized);
|
|
1100
|
+
if (dist < threshold) candidates.push({
|
|
1101
|
+
oldIdx: old.idx,
|
|
1102
|
+
newIdx: nw.idx,
|
|
1103
|
+
distance: dist
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
1107
|
+
const matched = [];
|
|
1108
|
+
const matchedOldIndices = /* @__PURE__ */ new Set();
|
|
1109
|
+
const matchedNewIndices = /* @__PURE__ */ new Set();
|
|
1110
|
+
for (const c of candidates) {
|
|
1111
|
+
if (matchedOldIndices.has(c.oldIdx) || matchedNewIndices.has(c.newIdx)) continue;
|
|
1112
|
+
matched.push({
|
|
1113
|
+
oldReq: oldReqs[c.oldIdx],
|
|
1114
|
+
newReq: newReqs[c.newIdx],
|
|
1115
|
+
strategy: "vendorFuzzyTitle",
|
|
1116
|
+
confidence: 1 - c.distance,
|
|
1117
|
+
relationship: "primary"
|
|
1118
|
+
});
|
|
1119
|
+
matchedOldIndices.add(c.oldIdx);
|
|
1120
|
+
matchedNewIndices.add(c.newIdx);
|
|
1121
|
+
}
|
|
1122
|
+
const unmatchedOld = [];
|
|
1123
|
+
for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) unmatchedOld.push(oldReqs[i]);
|
|
1124
|
+
const unmatchedNew = [];
|
|
1125
|
+
for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) unmatchedNew.push(newReqs[i]);
|
|
1126
|
+
return {
|
|
1127
|
+
matched,
|
|
1128
|
+
unmatchedOld,
|
|
1129
|
+
unmatchedNew
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
651
1135
|
//#region src/matching/index.ts
|
|
652
1136
|
/**
|
|
653
1137
|
* Create a strategy instance by name, using the provided options for
|
|
@@ -659,6 +1143,9 @@ function createStrategy(name, options) {
|
|
|
659
1143
|
case "mappedId": return createMappedIdStrategy(options.mappingTable ?? {});
|
|
660
1144
|
case "cciMatch": return createCciMatchStrategy();
|
|
661
1145
|
case "fuzzyTitle": return createFuzzyTitleStrategy(options.minConfidence);
|
|
1146
|
+
case "srgDeterministic": return createSrgDeterministicStrategy();
|
|
1147
|
+
case "srgCciTiebreak": return createSrgCciTiebreakStrategy();
|
|
1148
|
+
case "vendorFuzzyTitle": return createVendorFuzzyTitleStrategy(options.minConfidence !== void 0 && options.minConfidence > 0 ? 1 - options.minConfidence : void 0);
|
|
662
1149
|
default: throw new Error(`Unknown matching strategy: '${name}'`);
|
|
663
1150
|
}
|
|
664
1151
|
}
|
|
@@ -1168,13 +1655,26 @@ function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, t
|
|
|
1168
1655
|
requirementDiffs
|
|
1169
1656
|
};
|
|
1170
1657
|
}
|
|
1658
|
+
/** Field names handled by dedicated CVE-ecosystem diff handlers (not generic JSON compare). */
|
|
1659
|
+
const CVE_ECOSYSTEM_FIELDS = new Set([
|
|
1660
|
+
"cvss",
|
|
1661
|
+
"epss",
|
|
1662
|
+
"kev",
|
|
1663
|
+
"cwe",
|
|
1664
|
+
"affectedPackages"
|
|
1665
|
+
]);
|
|
1171
1666
|
/**
|
|
1172
1667
|
* Compute field-level changes for tracked fields between two requirements.
|
|
1173
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.
|
|
1174
1673
|
*/
|
|
1175
1674
|
function computeFieldChanges(oldReq, newReq, trackedFields) {
|
|
1176
1675
|
const changes = [];
|
|
1177
1676
|
for (const field of trackedFields) {
|
|
1677
|
+
if (CVE_ECOSYSTEM_FIELDS.has(field)) continue;
|
|
1178
1678
|
const oldVal = oldReq[field];
|
|
1179
1679
|
const newVal = newReq[field];
|
|
1180
1680
|
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) if (oldVal === void 0 && newVal !== void 0) changes.push({
|
|
@@ -1194,6 +1694,367 @@ function computeFieldChanges(oldReq, newReq, trackedFields) {
|
|
|
1194
1694
|
newValue: newVal
|
|
1195
1695
|
});
|
|
1196
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 = 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 = 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 = 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 = 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 = 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 = 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 = 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
|
+
}
|
|
1197
2058
|
return changes;
|
|
1198
2059
|
}
|
|
1199
2060
|
/**
|
|
@@ -1670,7 +2531,7 @@ function stripSnapshots(req) {
|
|
|
1670
2531
|
return rest;
|
|
1671
2532
|
}
|
|
1672
2533
|
/**
|
|
1673
|
-
* Render an
|
|
2534
|
+
* Render an HDFComparison as a JSON string.
|
|
1674
2535
|
*
|
|
1675
2536
|
* - `detail: 'summary'` -- only `{ formatVersion, comparisonMode, summary }`
|
|
1676
2537
|
* - `detail: 'control'` -- full document but `before`/`after` stripped from requirementDiffs
|
|
@@ -1784,7 +2645,7 @@ function renderStateSection(state, diffs, detail) {
|
|
|
1784
2645
|
return lines.join("\n");
|
|
1785
2646
|
}
|
|
1786
2647
|
/**
|
|
1787
|
-
* Render an
|
|
2648
|
+
* Render an HDFComparison as a Markdown string.
|
|
1788
2649
|
*
|
|
1789
2650
|
* - `detail: 'summary'` -- summary table only
|
|
1790
2651
|
* - `detail: 'control'` -- summary + per-requirement tables by state
|
|
@@ -1920,7 +2781,7 @@ function buildHeaderLine(comparison, useColor) {
|
|
|
1920
2781
|
return header;
|
|
1921
2782
|
}
|
|
1922
2783
|
/**
|
|
1923
|
-
* Render an
|
|
2784
|
+
* Render an HDFComparison for terminal display with optional ANSI colors.
|
|
1924
2785
|
*
|
|
1925
2786
|
* - `detail: 'summary'` -- just the summary line
|
|
1926
2787
|
* - `detail: 'control'` -- requirement list + summary (excludes unchanged)
|
|
@@ -1973,7 +2834,7 @@ function formatFieldChanges(req) {
|
|
|
1973
2834
|
}).join("; ");
|
|
1974
2835
|
}
|
|
1975
2836
|
/**
|
|
1976
|
-
* Render an
|
|
2837
|
+
* Render an HDFComparison as a CSV string.
|
|
1977
2838
|
*
|
|
1978
2839
|
* One row per requirement. Columns:
|
|
1979
2840
|
* - ID, Title, State, Old Status, New Status, Impact (Old), Impact (New), Change Reasons
|
|
@@ -2019,7 +2880,7 @@ function renderCsv(comparison, options) {
|
|
|
2019
2880
|
/**
|
|
2020
2881
|
* Convenience function to render a comparison in any supported format.
|
|
2021
2882
|
*
|
|
2022
|
-
* @param comparison - The
|
|
2883
|
+
* @param comparison - The HDFComparison document to render
|
|
2023
2884
|
* @param format - Output format: 'json', 'markdown', 'terminal', or 'csv'
|
|
2024
2885
|
* @param options - Rendering options (detail level, filters, color)
|
|
2025
2886
|
* @returns The rendered string
|
|
@@ -2033,6 +2894,6 @@ function render(comparison, format, options) {
|
|
|
2033
2894
|
}
|
|
2034
2895
|
}
|
|
2035
2896
|
//#endregion
|
|
2036
|
-
export { EXIT_DETAILED_BASELINE_CHANGED, EXIT_DETAILED_DRIFT_ONLY, EXIT_DETAILED_ERROR, EXIT_DETAILED_FIXES_ONLY, EXIT_DETAILED_IDENTICAL, EXIT_DETAILED_MIXED, EXIT_DETAILED_REGRESSIONS_ONLY, EXIT_DIFFERENCES, EXIT_ERROR, EXIT_IDENTICAL, classifyChangeReasons, classifyDiffStatus, computeDetailedExitCode, computeEffectiveStatus, computeExitCode, computeSummary, createCciMatchStrategy, createExactIdStrategy, createFuzzyTitleStrategy, createMappedIdStrategy, diffBaselines, diffHdf, diffSboms, diffSystems, isV1Format, jaccardSimilarity, matchRequirements, normalizeToV2, render, renderCsv, renderJson, renderMarkdown, renderTerminal, tokenize, validateComparison };
|
|
2897
|
+
export { EXIT_DETAILED_BASELINE_CHANGED, EXIT_DETAILED_DRIFT_ONLY, EXIT_DETAILED_ERROR, EXIT_DETAILED_FIXES_ONLY, EXIT_DETAILED_IDENTICAL, EXIT_DETAILED_MIXED, EXIT_DETAILED_REGRESSIONS_ONLY, EXIT_DIFFERENCES, EXIT_ERROR, EXIT_IDENTICAL, classifyChangeReasons, classifyDiffStatus, computeDetailedExitCode, computeEffectiveStatus, computeExitCode, computeSummary, createCciMatchStrategy, createExactIdStrategy, createFuzzyTitleStrategy, createMappedIdStrategy, createSrgCciTiebreakStrategy, createSrgDeterministicStrategy, createVendorFuzzyTitleStrategy, diffBaselines, diffHdf, diffSboms, diffSystems, isV1Format, jaccardSimilarity, levenshteinDistance, matchRequirements, normalizeToV2, normalizedLevenshtein, render, renderCsv, renderJson, renderMarkdown, renderTerminal, tokenize, validateComparison };
|
|
2037
2898
|
|
|
2038
2899
|
//# sourceMappingURL=index.js.map
|