monkeypatchlist 0.0.0-stage → 0.1.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.
Files changed (221) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +61 -0
  3. package/README.md +183 -2
  4. package/catalog/catalog.json +552211 -0
  5. package/catalog/catalog.schema.json +787 -0
  6. package/catalog/changelog.json +3 -0
  7. package/dist/bin/monkeypatchlist.d.ts +2 -0
  8. package/dist/bin/monkeypatchlist.js +3 -0
  9. package/dist/catalog.d.ts +561 -0
  10. package/dist/catalog.js +58 -0
  11. package/dist/cli.d.ts +84 -0
  12. package/dist/cli.js +470 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +1 -0
  15. package/dist/lockfile-input.d.ts +9 -0
  16. package/dist/lockfile-input.js +91 -0
  17. package/dist/packages/analysis/binding.d.ts +2 -0
  18. package/dist/packages/analysis/binding.js +5 -0
  19. package/dist/packages/analysis/index.d.ts +10 -0
  20. package/dist/packages/analysis/index.js +13 -0
  21. package/dist/packages/analysis/job.d.ts +33 -0
  22. package/dist/packages/analysis/job.js +85 -0
  23. package/dist/packages/analysis/lib/absence-tags.d.ts +34 -0
  24. package/dist/packages/analysis/lib/absence-tags.js +340 -0
  25. package/dist/packages/analysis/lib/ast.d.ts +28 -0
  26. package/dist/packages/analysis/lib/ast.js +64 -0
  27. package/dist/packages/analysis/lib/binding.d.ts +36 -0
  28. package/dist/packages/analysis/lib/binding.js +66 -0
  29. package/dist/packages/analysis/lib/budget.d.ts +80 -0
  30. package/dist/packages/analysis/lib/budget.js +31 -0
  31. package/dist/packages/analysis/lib/catch-arms.d.ts +19 -0
  32. package/dist/packages/analysis/lib/catch-arms.js +351 -0
  33. package/dist/packages/analysis/lib/chunk-loads.d.ts +160 -0
  34. package/dist/packages/analysis/lib/chunk-loads.js +882 -0
  35. package/dist/packages/analysis/lib/chunk-registry.d.ts +45 -0
  36. package/dist/packages/analysis/lib/chunk-registry.js +334 -0
  37. package/dist/packages/analysis/lib/code-routes.d.ts +52 -0
  38. package/dist/packages/analysis/lib/code-routes.js +921 -0
  39. package/dist/packages/analysis/lib/collisions.d.ts +22 -0
  40. package/dist/packages/analysis/lib/collisions.js +103 -0
  41. package/dist/packages/analysis/lib/definitions.d.ts +76 -0
  42. package/dist/packages/analysis/lib/definitions.js +782 -0
  43. package/dist/packages/analysis/lib/early-exits.d.ts +39 -0
  44. package/dist/packages/analysis/lib/early-exits.js +161 -0
  45. package/dist/packages/analysis/lib/effect.d.ts +138 -0
  46. package/dist/packages/analysis/lib/effect.js +800 -0
  47. package/dist/packages/analysis/lib/global-object.d.ts +581 -0
  48. package/dist/packages/analysis/lib/global-object.js +8240 -0
  49. package/dist/packages/analysis/lib/keys.d.ts +33 -0
  50. package/dist/packages/analysis/lib/keys.js +82 -0
  51. package/dist/packages/analysis/lib/line-starts.d.ts +41 -0
  52. package/dist/packages/analysis/lib/line-starts.js +79 -0
  53. package/dist/packages/analysis/lib/link-group.d.ts +51 -0
  54. package/dist/packages/analysis/lib/link-group.js +487 -0
  55. package/dist/packages/analysis/lib/module-links.d.ts +117 -0
  56. package/dist/packages/analysis/lib/module-links.js +1060 -0
  57. package/dist/packages/analysis/lib/nested-code.d.ts +38 -0
  58. package/dist/packages/analysis/lib/nested-code.js +200 -0
  59. package/dist/packages/analysis/lib/numeric.d.ts +16 -0
  60. package/dist/packages/analysis/lib/numeric.js +216 -0
  61. package/dist/packages/analysis/lib/once-flags.d.ts +12 -0
  62. package/dist/packages/analysis/lib/once-flags.js +190 -0
  63. package/dist/packages/analysis/lib/parameter-arguments.d.ts +12 -0
  64. package/dist/packages/analysis/lib/parameter-arguments.js +34 -0
  65. package/dist/packages/analysis/lib/parse.d.ts +10 -0
  66. package/dist/packages/analysis/lib/parse.js +39 -0
  67. package/dist/packages/analysis/lib/partial.d.ts +60 -0
  68. package/dist/packages/analysis/lib/partial.js +518 -0
  69. package/dist/packages/analysis/lib/print.d.ts +3 -0
  70. package/dist/packages/analysis/lib/print.js +150 -0
  71. package/dist/packages/analysis/lib/reachability.d.ts +137 -0
  72. package/dist/packages/analysis/lib/reachability.js +1295 -0
  73. package/dist/packages/analysis/lib/resolver.d.ts +13 -0
  74. package/dist/packages/analysis/lib/resolver.js +2886 -0
  75. package/dist/packages/analysis/lib/restores.d.ts +42 -0
  76. package/dist/packages/analysis/lib/restores.js +482 -0
  77. package/dist/packages/analysis/lib/rewritten-deletes.d.ts +6 -0
  78. package/dist/packages/analysis/lib/rewritten-deletes.js +142 -0
  79. package/dist/packages/analysis/lib/scope.d.ts +110 -0
  80. package/dist/packages/analysis/lib/scope.js +443 -0
  81. package/dist/packages/analysis/lib/script-loads.d.ts +56 -0
  82. package/dist/packages/analysis/lib/script-loads.js +359 -0
  83. package/dist/packages/analysis/lib/summaries.d.ts +177 -0
  84. package/dist/packages/analysis/lib/summaries.js +498 -0
  85. package/dist/packages/analysis/lib/types.d.ts +632 -0
  86. package/dist/packages/analysis/lib/types.js +128 -0
  87. package/dist/packages/analysis/lib/wrap.d.ts +34 -0
  88. package/dist/packages/analysis/lib/wrap.js +407 -0
  89. package/dist/packages/analysis/lib/writes.d.ts +358 -0
  90. package/dist/packages/analysis/lib/writes.js +1612 -0
  91. package/dist/packages/analysis/visit-order.d.ts +3 -0
  92. package/dist/packages/analysis/visit-order.js +23 -0
  93. package/dist/packages/attribution/index.d.ts +233 -0
  94. package/dist/packages/attribution/index.js +444 -0
  95. package/dist/packages/attribution/lib/containing-package.d.ts +76 -0
  96. package/dist/packages/attribution/lib/containing-package.js +304 -0
  97. package/dist/packages/attribution/lib/first-party.d.ts +37 -0
  98. package/dist/packages/attribution/lib/first-party.js +153 -0
  99. package/dist/packages/attribution/lib/lockfile-graph.d.ts +99 -0
  100. package/dist/packages/attribution/lib/lockfile-graph.js +158 -0
  101. package/dist/packages/attribution/lib/lockfile.d.ts +33 -0
  102. package/dist/packages/attribution/lib/lockfile.js +330 -0
  103. package/dist/packages/attribution/lib/path-rules.d.ts +29 -0
  104. package/dist/packages/attribution/lib/path-rules.js +84 -0
  105. package/dist/packages/attribution/lib/pnpm.d.ts +4 -0
  106. package/dist/packages/attribution/lib/pnpm.js +133 -0
  107. package/dist/packages/attribution/lib/remote-url.d.ts +7 -0
  108. package/dist/packages/attribution/lib/remote-url.js +24 -0
  109. package/dist/packages/attribution/lib/source-map.d.ts +51 -0
  110. package/dist/packages/attribution/lib/source-map.js +125 -0
  111. package/dist/packages/attribution/lib/yaml.d.ts +9 -0
  112. package/dist/packages/attribution/lib/yaml.js +277 -0
  113. package/dist/packages/attribution/lib/yarn.d.ts +7 -0
  114. package/dist/packages/attribution/lib/yarn.js +227 -0
  115. package/dist/packages/baseline/index.d.ts +45 -0
  116. package/dist/packages/baseline/index.js +41 -0
  117. package/dist/packages/baseline/lib/error.d.ts +15 -0
  118. package/dist/packages/baseline/lib/error.js +25 -0
  119. package/dist/packages/baseline/lib/format.d.ts +49 -0
  120. package/dist/packages/baseline/lib/format.js +22 -0
  121. package/dist/packages/baseline/lib/migrations.d.ts +19 -0
  122. package/dist/packages/baseline/lib/migrations.js +7 -0
  123. package/dist/packages/baseline/lib/previous.d.ts +23 -0
  124. package/dist/packages/baseline/lib/previous.js +64 -0
  125. package/dist/packages/baseline/lib/read.d.ts +39 -0
  126. package/dist/packages/baseline/lib/read.js +219 -0
  127. package/dist/packages/baseline/lib/report.d.ts +25 -0
  128. package/dist/packages/baseline/lib/report.js +104 -0
  129. package/dist/packages/baseline/lib/skew.d.ts +51 -0
  130. package/dist/packages/baseline/lib/skew.js +75 -0
  131. package/dist/packages/compare/index.d.ts +120 -0
  132. package/dist/packages/compare/index.js +274 -0
  133. package/dist/packages/compare/lib/catalog-notes.d.ts +73 -0
  134. package/dist/packages/compare/lib/catalog-notes.js +224 -0
  135. package/dist/packages/completeness/index.d.ts +246 -0
  136. package/dist/packages/completeness/index.js +249 -0
  137. package/dist/packages/confidence/index.d.ts +54 -0
  138. package/dist/packages/confidence/index.js +71 -0
  139. package/dist/packages/config/index.d.ts +107 -0
  140. package/dist/packages/config/index.js +95 -0
  141. package/dist/packages/config/lib/command-line.d.ts +23 -0
  142. package/dist/packages/config/lib/command-line.js +239 -0
  143. package/dist/packages/config/lib/config-file.d.ts +11 -0
  144. package/dist/packages/config/lib/config-file.js +119 -0
  145. package/dist/packages/config/lib/scan-config-id.d.ts +28 -0
  146. package/dist/packages/config/lib/scan-config-id.js +54 -0
  147. package/dist/packages/config/lib/schema.d.ts +2 -0
  148. package/dist/packages/config/lib/schema.js +31 -0
  149. package/dist/packages/config/lib/types.d.ts +64 -0
  150. package/dist/packages/config/lib/types.js +6 -0
  151. package/dist/packages/config/lib/urls.d.ts +6 -0
  152. package/dist/packages/config/lib/urls.js +9 -0
  153. package/dist/packages/discovery/index.d.ts +277 -0
  154. package/dist/packages/discovery/index.js +1950 -0
  155. package/dist/packages/discovery/lib/import-map.d.ts +68 -0
  156. package/dist/packages/discovery/lib/import-map.js +180 -0
  157. package/dist/packages/discovery/lib/requests.d.ts +58 -0
  158. package/dist/packages/discovery/lib/requests.js +591 -0
  159. package/dist/packages/discovery/lib/scopes.d.ts +6 -0
  160. package/dist/packages/discovery/lib/scopes.js +286 -0
  161. package/dist/packages/discovery/lib/source-maps.d.ts +56 -0
  162. package/dist/packages/discovery/lib/source-maps.js +209 -0
  163. package/dist/packages/discovery/lib/swept-code.d.ts +27 -0
  164. package/dist/packages/discovery/lib/swept-code.js +107 -0
  165. package/dist/packages/discovery/lib/syntax.d.ts +17 -0
  166. package/dist/packages/discovery/lib/syntax.js +39 -0
  167. package/dist/packages/findings/index.d.ts +106 -0
  168. package/dist/packages/findings/index.js +254 -0
  169. package/dist/packages/findings/lib/scope.d.ts +38 -0
  170. package/dist/packages/findings/lib/scope.js +110 -0
  171. package/dist/packages/parallel/index.d.ts +2 -0
  172. package/dist/packages/parallel/index.js +5 -0
  173. package/dist/packages/parallel/lib/protocol.d.ts +15 -0
  174. package/dist/packages/parallel/lib/protocol.js +2 -0
  175. package/dist/packages/parallel/lib/runner.d.ts +36 -0
  176. package/dist/packages/parallel/lib/runner.js +96 -0
  177. package/dist/packages/parallel/lib/schedule.d.ts +16 -0
  178. package/dist/packages/parallel/lib/schedule.js +52 -0
  179. package/dist/packages/parallel/lib/worker.d.ts +1 -0
  180. package/dist/packages/parallel/lib/worker.js +18 -0
  181. package/dist/packages/policy/index.d.ts +36 -0
  182. package/dist/packages/policy/index.js +22 -0
  183. package/dist/packages/report/explain.d.ts +26 -0
  184. package/dist/packages/report/explain.js +99 -0
  185. package/dist/packages/report/index.d.ts +210 -0
  186. package/dist/packages/report/index.js +388 -0
  187. package/dist/packages/report/terminal.d.ts +11 -0
  188. package/dist/packages/report/terminal.js +331 -0
  189. package/dist/packages/source-store/fixture.d.ts +22 -0
  190. package/dist/packages/source-store/fixture.js +58 -0
  191. package/dist/packages/source-store/index.d.ts +278 -0
  192. package/dist/packages/source-store/index.js +322 -0
  193. package/dist/packages/source-store/lib/addresses.d.ts +6 -0
  194. package/dist/packages/source-store/lib/addresses.js +91 -0
  195. package/dist/packages/source-store/lib/connection.d.ts +15 -0
  196. package/dist/packages/source-store/lib/connection.js +135 -0
  197. package/dist/packages/source-store/lib/content-types.d.ts +16 -0
  198. package/dist/packages/source-store/lib/content-types.js +75 -0
  199. package/dist/packages/source-store/lib/data-urls.d.ts +10 -0
  200. package/dist/packages/source-store/lib/data-urls.js +85 -0
  201. package/dist/packages/source-store/lib/deadline.d.ts +18 -0
  202. package/dist/packages/source-store/lib/deadline.js +44 -0
  203. package/dist/packages/source-store/lib/exchange.d.ts +29 -0
  204. package/dist/packages/source-store/lib/exchange.js +112 -0
  205. package/dist/packages/source-store/lib/integrity.d.ts +19 -0
  206. package/dist/packages/source-store/lib/integrity.js +86 -0
  207. package/dist/packages/source-store/lib/outcomes.d.ts +12 -0
  208. package/dist/packages/source-store/lib/outcomes.js +22 -0
  209. package/dist/packages/source-store/lib/proxy.d.ts +11 -0
  210. package/dist/packages/source-store/lib/proxy.js +53 -0
  211. package/dist/packages/source-store/lib/session.d.ts +12 -0
  212. package/dist/packages/source-store/lib/session.js +139 -0
  213. package/dist/packages/source-store/lib/urls.d.ts +6 -0
  214. package/dist/packages/source-store/lib/urls.js +15 -0
  215. package/dist/packages/source-store/resolve.d.ts +59 -0
  216. package/dist/packages/source-store/resolve.js +45 -0
  217. package/dist/scan.d.ts +65 -0
  218. package/dist/scan.js +592 -0
  219. package/package.json +95 -4
  220. package/schema/config.schema.json +133 -0
  221. package/schema/report.schema.json +1905 -0
@@ -0,0 +1,274 @@
1
+ // Compare (§19, module 7): matches findings against a baseline or previous report, giving baseline states,
2
+ // `changedReasons` and notes (§11). It knows nothing of the policy.
3
+ import { installContexts, reachabilities, } from "../analysis/index.js";
4
+ import { compareStrings, targetKey } from "../findings/index.js";
5
+ export { withCatalogStatus, withExplainedBy } from "./lib/catalog-notes.js";
6
+ /**
7
+ * The limitation kinds that say a scan skipped or refused a remote script (§13): `offline-skipped` in `offline` mode,
8
+ * and `remote-refused` by the safety floor (§17). A remote baseline entry may be in the code the scan didn't see.
9
+ */
10
+ const remoteGapKinds = new Set(['offline-skipped', 'remote-refused']);
11
+ /**
12
+ * Compares findings with the baseline (§11 Baseline states and matching), in order:
13
+ *
14
+ * 1. By exact fingerprint.
15
+ * 2. The effect pass: a finding and an entry left over with the same realm, target and attribution key but a different
16
+ * effect pair when each is the only one left in that group (1:1). A paired finding is `changed`, reason `effect`.
17
+ * 3. The target-key pass, over the findings and entries left over by target key `(realm, target, effect)`, plus any
18
+ * `unknown` finding or entry the exact pass matched. A known key pairs with a known key 1:1 only: `changed`,
19
+ * reason `attribution`. An `unknown` finding pairs with every known entry left over in its group, and an `unknown`
20
+ * entry with every known finding left over, as a `rekeyed` pair; both rules apply in one group. The known
21
+ * findings and entries of a group with both but not 1:1 that nothing pairs stay `added` and `removed`, with a
22
+ * `shared-target-key` note.
23
+ *
24
+ * A paired finding whose `locks` went `true` ↔ `false` against any entry it pairs with is `changed`, reason `locks`,
25
+ * once per such entry; `unknown` on either side is no change. A paired finding with no reason is `existing`, and a
26
+ * finding nothing paired is `added`. An entry that matches no finding is `unconfirmed` when the scan could not have seen
27
+ * it (§13 Effects on the baseline and the gate): it is `incomplete`, a link group exhausted its step budget, or the entry
28
+ * is remote and a remote script was skipped. It is `removed` otherwise. Without a baseline, every finding's state is
29
+ * null. The result is sorted by fingerprint.
30
+ *
31
+ * Whatever its state, a finding whose containing package has a strong version in both the scan's `packages` map and
32
+ * the baseline's, and they differ, gets a `previous-version` note (§11 `previousVersion`). The version never decides a
33
+ * state.
34
+ *
35
+ * With `previous`, the values of a previous scan, an `existing` or `changed` finding whose reachability or install
36
+ * context differs from the previous value of a baseline entry it paired with gets a `reachability-change` or
37
+ * `install-context-change` note, one per distinct previous value (§11 Never `changed`). The previous value is the
38
+ * one under the entry's fingerprint, so it follows the pairing, a `rekeyed` one included. These notes never decide a
39
+ * state, and need a baseline.
40
+ */
41
+ export function compare(findings, baseline, completeness, packages = {}, previous = null) {
42
+ if (baseline === null) {
43
+ return findings.map((finding) => ({
44
+ ...finding,
45
+ baselineState: null,
46
+ matchedBy: null,
47
+ changedReasons: [],
48
+ notes: [],
49
+ }));
50
+ }
51
+ return states(findings, baseline, completeness).map(([finding, paired]) => {
52
+ const notes = [...finding.notes];
53
+ const name = finding.attribution.package;
54
+ const [before, after] = name === undefined ? [] : [baseline.packages[name], packages[name]];
55
+ if (name !== undefined && before !== undefined && after !== undefined && before.version !== after.version) {
56
+ notes.push({ kind: 'previous-version', package: name, before: before.version, after: after.version });
57
+ }
58
+ if (previous !== null)
59
+ notes.push(...previousNotes(finding, paired, previous));
60
+ return notes.length === finding.notes.length ? finding : { ...finding, notes };
61
+ });
62
+ }
63
+ /**
64
+ * A paired finding's `reachability-change` and `install-context-change` notes (§11 Never `changed`): for each attribute,
65
+ * one per distinct value that the previous scan gave a paired entry's fingerprint and that differs from the finding's
66
+ * own, in the attribute's order.
67
+ */
68
+ function previousNotes(finding, paired, previous) {
69
+ const { reachability, installContext } = finding;
70
+ if (reachability === null || installContext === null)
71
+ return [];
72
+ const values = paired.flatMap(({ fingerprint }) => previous.get(fingerprint) ?? []);
73
+ const differing = (order, before, after) => order.filter((value) => value !== after && before.includes(value));
74
+ return [
75
+ ...differing(reachabilities, values.map((value) => value.reachability), reachability).map((before) => ({ kind: 'reachability-change', before, after: reachability })),
76
+ ...differing(installContexts, values.map((value) => value.installContext), installContext).map((before) => ({ kind: 'install-context-change', before, after: installContext })),
77
+ ];
78
+ }
79
+ /**
80
+ * Each finding's baseline state, how it was matched, its `changedReasons` and its matching notes (§11), with the
81
+ * baseline entries it paired with (none for an `added` finding or an entry the scan didn't find).
82
+ */
83
+ function states(findings, baseline, completeness) {
84
+ const entries = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry]));
85
+ const pairs = new Map();
86
+ const pair = (finding, pairing) => {
87
+ const found = pairs.get(finding);
88
+ if (found === undefined)
89
+ pairs.set(finding, [pairing]);
90
+ else
91
+ found.push(pairing);
92
+ };
93
+ // `unknown` findings and entries the exact or the effect pass paired still take part in the target-key pass.
94
+ const rejoin = { findings: [], entries: [] };
95
+ const rejoinUnknown = (finding, entry) => {
96
+ if (finding.attribution.key === unknownKey)
97
+ rejoin.findings.push(finding);
98
+ if (entry.attribution.key === unknownKey)
99
+ rejoin.entries.push(entry);
100
+ };
101
+ for (const finding of findings) {
102
+ const entry = entries.get(finding.fingerprint);
103
+ if (entry === undefined)
104
+ continue;
105
+ pair(finding, { entry, by: 'fingerprint' });
106
+ entries.delete(finding.fingerprint);
107
+ rejoinUnknown(finding, entry);
108
+ }
109
+ for (const [finding, entry] of effectPairs(findings.filter((finding) => !pairs.has(finding)), [...entries.values()])) {
110
+ pair(finding, { entry, by: 'effect' });
111
+ entries.delete(entry.fingerprint);
112
+ rejoinUnknown(finding, entry);
113
+ }
114
+ const shared = new Map();
115
+ for (const [finding, pairing] of targetKeyPairs([...findings.filter((finding) => !pairs.has(finding)), ...rejoin.findings], [...entries.values(), ...rejoin.entries], shared)) {
116
+ pair(finding, pairing);
117
+ entries.delete(pairing.entry.fingerprint);
118
+ }
119
+ const scanned = findings.map((finding) => {
120
+ const notes = sharedNote(shared.get(finding));
121
+ const pairings = pairs.get(finding);
122
+ if (pairings === undefined) {
123
+ return [{ ...finding, baselineState: 'added', matchedBy: null, changedReasons: [], notes }, []];
124
+ }
125
+ const changedReasons = pairings
126
+ .flatMap(({ entry, by }) => reasons(entry, finding, by))
127
+ .sort((a, b) => reasonOrder.indexOf(a.reason) - reasonOrder.indexOf(b.reason));
128
+ const rekeyed = [...new Set(pairings.flatMap(({ entry, by }) => (by === 'rekeyed' ? [entry.attribution.key] : [])))];
129
+ const compared = {
130
+ ...finding,
131
+ baselineState: changedReasons.length === 0 ? 'existing' : 'changed',
132
+ matchedBy: matchedBy(pairings[0].by),
133
+ changedReasons,
134
+ notes: rekeyed.length === 0
135
+ ? notes
136
+ : [{ kind: 'rekeyed', before: rekeyed.sort(compareStrings), after: finding.attribution.key }, ...notes],
137
+ };
138
+ return [compared, pairings.map(({ entry }) => entry)];
139
+ });
140
+ // An incomplete scan failed to analyze something its configuration asks for, so it can't confirm that any entry is
141
+ // gone, and nor can one in which any link group exhausted its step budget; a Principle 5 cap cut alone can. A
142
+ // scan that skipped or refused a remote script can't confirm that a remote entry is.
143
+ const incomplete = completeness.level === 'incomplete' || completeness.counters.statistics.budgetExhausted > 0;
144
+ const remoteGap = completeness.limitations.some(({ kind }) => remoteGapKinds.has(kind));
145
+ const unseen = ({ attribution }) => incomplete || (remoteGap && attribution.provenance === 'remote');
146
+ const missing = [...entries.values()].map((entry) => [
147
+ unmatched(entry, unseen(entry) ? 'unconfirmed' : 'removed', sharedNote(shared.get(entry))),
148
+ [],
149
+ ]);
150
+ return [...scanned, ...missing].sort(([a], [b]) => compareStrings(a.fingerprint, b.fingerprint));
151
+ }
152
+ /** The attribution key of code that can't be attributed (§11). */
153
+ const unknownKey = 'unknown';
154
+ /** The §11 table's order of `changed` reasons. */
155
+ const reasonOrder = ['attribution', 'effect', 'locks'];
156
+ const matchedBy = (by) => (by === 'attribution' || by === 'rekeyed' ? 'target-key' : by);
157
+ const sharedNote = (fingerprints) => fingerprints === undefined ? [] : [{ kind: 'shared-target-key', fingerprints }];
158
+ /**
159
+ * The target-key pass (§11 Baseline states and matching, step 3) over `findings` and `entries` grouped by target key.
160
+ * In each group, a lone known finding and a lone known entry pair (`attribution`); an `unknown` finding pairs with every
161
+ * known entry, and an `unknown` entry with every known finding (`rekeyed`). A group whose known findings and entries
162
+ * aren't 1:1 is ambiguous: `shared` gets, for each of its known members nothing pairs, the other side's fingerprints.
163
+ */
164
+ function targetKeyPairs(findings, entries, shared) {
165
+ const groups = new Map();
166
+ const group = (key) => {
167
+ let found = groups.get(key);
168
+ if (found === undefined)
169
+ groups.set(key, (found = { findings: [], entries: [] }));
170
+ return found;
171
+ };
172
+ for (const finding of findings)
173
+ group(targetKey(finding.target, finding.effect)).findings.push(finding);
174
+ for (const entry of entries)
175
+ group(targetKey(entry.target, entry.effect)).entries.push(entry);
176
+ const pairs = [];
177
+ for (const { findings: groupFindings, entries: groupEntries } of groups.values()) {
178
+ const knownFindings = groupFindings.filter(({ attribution }) => attribution.key !== unknownKey);
179
+ const knownEntries = groupEntries.filter(({ attribution }) => attribution.key !== unknownKey);
180
+ const unknownFinding = groupFindings.find(({ attribution }) => attribution.key === unknownKey);
181
+ const unknownEntry = groupEntries.find(({ attribution }) => attribution.key === unknownKey);
182
+ const paired = new Set();
183
+ const add = (finding, entry, by) => {
184
+ pairs.push([finding, { entry, by }]);
185
+ paired.add(finding).add(entry);
186
+ };
187
+ const [knownFinding] = knownFindings;
188
+ const [knownEntry] = knownEntries;
189
+ if (knownFindings.length === 1 && knownEntries.length === 1)
190
+ add(knownFinding, knownEntry, 'attribution');
191
+ if (unknownFinding !== undefined)
192
+ for (const entry of knownEntries)
193
+ add(unknownFinding, entry, 'rekeyed');
194
+ if (unknownEntry !== undefined)
195
+ for (const finding of knownFindings)
196
+ add(finding, unknownEntry, 'rekeyed');
197
+ if (knownFinding === undefined || knownEntry === undefined)
198
+ continue;
199
+ for (const finding of knownFindings) {
200
+ if (!paired.has(finding))
201
+ shared.set(finding, knownEntries.map(({ fingerprint }) => fingerprint).sort(compareStrings));
202
+ }
203
+ for (const entry of knownEntries) {
204
+ if (!paired.has(entry))
205
+ shared.set(entry, knownFindings.map(({ fingerprint }) => fingerprint).sort(compareStrings));
206
+ }
207
+ }
208
+ return pairs;
209
+ }
210
+ /** A finding's or entry's `(realm, target, attribution key)`: its fingerprint without the effect (§11). */
211
+ const effectGroup = ({ target, attribution }) => `${target.realm}|${target.name}|${attribution.key}`;
212
+ /**
213
+ * The effect pass (§11 Baseline states and matching, step 2): the findings and entries left over, grouped by `(realm,
214
+ * target, attribution key)`, pair where a group holds exactly one of each. They differ in effect, since the exact
215
+ * fingerprints didn't match. Any other group is ambiguous and pairs nothing here.
216
+ */
217
+ function effectPairs(findings, entries) {
218
+ const groups = new Map();
219
+ const group = (key) => {
220
+ let found = groups.get(key);
221
+ if (found === undefined)
222
+ groups.set(key, (found = { findings: [], entries: [] }));
223
+ return found;
224
+ };
225
+ for (const finding of findings)
226
+ group(effectGroup(finding)).findings.push(finding);
227
+ for (const entry of entries)
228
+ group(effectGroup(entry)).entries.push(entry);
229
+ return [...groups.values()].flatMap(({ findings: [finding, ...moreFindings], entries: [entry, ...moreEntries] }) => finding !== undefined && entry !== undefined && moreFindings.length === 0 && moreEntries.length === 0
230
+ ? [[finding, entry]]
231
+ : []);
232
+ }
233
+ /**
234
+ * Why a finding paired with `entry` is `changed` (§11 `changed`), before → after, in the table's order. The comparison
235
+ * is symmetric. Nothing else, such as mechanism, confidence or occurrences, makes a finding `changed`.
236
+ */
237
+ function reasons(entry, finding, by) {
238
+ const changed = [];
239
+ if (by === 'attribution') {
240
+ changed.push({ reason: 'attribution', before: entry.attribution.key, after: finding.attribution.key });
241
+ }
242
+ if (entry.effect !== finding.effect)
243
+ changed.push({ reason: 'effect', before: entry.effect, after: finding.effect });
244
+ // A derived `locks` difference is a change `true` ↔ `false` only: `unknown` ↔ known is not.
245
+ const [before, after] = [entry.locks, finding.locks];
246
+ if (typeof before === 'boolean' && typeof after === 'boolean' && before !== after) {
247
+ changed.push({ reason: 'locks', before, after });
248
+ }
249
+ return changed;
250
+ }
251
+ /**
252
+ * A baseline entry no finding matched, `removed` or `unconfirmed`, reported with its readable context and no
253
+ * occurrences (§15 Findings).
254
+ */
255
+ function unmatched({ fingerprint, target, surface, effect, locks, attribution }, baselineState, notes) {
256
+ return {
257
+ fingerprint,
258
+ targetKey: targetKey(target, effect),
259
+ target,
260
+ surface,
261
+ effect,
262
+ locks,
263
+ attribution,
264
+ reachability: null,
265
+ reachabilityBasis: null,
266
+ installContext: null,
267
+ confidence: null,
268
+ occurrences: [],
269
+ baselineState,
270
+ matchedBy: null,
271
+ changedReasons: [],
272
+ notes,
273
+ };
274
+ }
@@ -0,0 +1,73 @@
1
+ import type { CatalogChangelog, ChangeRecord, Realm, SerializedCatalog } from '../../../catalog.ts';
2
+ import type { BaselineState } from '../../baseline/index.ts';
3
+ /** A finding's target as the catalog notes read it. */
4
+ type Targeted = {
5
+ target: {
6
+ realm: Realm;
7
+ name: string;
8
+ shadows?: string;
9
+ };
10
+ };
11
+ /**
12
+ * Sets `catalogStatus: retained` on each finding whose target, or the member it shadows, is a retained target of the
13
+ * catalog. It is information only.
14
+ */
15
+ export declare function withCatalogStatus<F extends Targeted>(findings: readonly F[], catalog: SerializedCatalog): (F & {
16
+ catalogStatus?: 'retained';
17
+ })[];
18
+ /** A change record as `explainedBy` carries it: with the catalog version that made the change. */
19
+ export type ExplainingRecord = ChangeRecord & {
20
+ catalogVersion: number;
21
+ };
22
+ /** What `explainedBy` reads of a compared finding. */
23
+ export type Explainable = Targeted & {
24
+ effect: string;
25
+ attribution: {
26
+ key: string;
27
+ };
28
+ baselineState: BaselineState | null;
29
+ changedReasons: readonly {
30
+ reason: string;
31
+ before: unknown;
32
+ after: unknown;
33
+ }[];
34
+ notes: readonly {
35
+ kind: string;
36
+ before?: unknown;
37
+ }[];
38
+ occurrences: readonly {
39
+ evidence: {
40
+ pathRule?: string;
41
+ };
42
+ }[];
43
+ };
44
+ /**
45
+ * Sets `explainedBy` (§4 Catalog versions) on each delta that a change record between the baseline's catalog version
46
+ * and the scan's could explain, with those records in changelog order. Only `added`, `removed` and `changed` findings,
47
+ * and `existing` ones with a `rekeyed` note, are deltas: never `unconfirmed` or plain `existing`. A baseline from the
48
+ * same or a newer catalog version gives none.
49
+ *
50
+ * A record is about a delta when it names the delta's target, the member the target shadows on the current side (its
51
+ * `shadows`) or the baseline side (worked out from the current catalog, with the record's own targets, since a
52
+ * `removed` entry stores none), or a wildcard's holder. Then, by the §4 table:
53
+ * - `target` added / dropped: `added` / `removed`, and `changed` (`effect`) where the finding was or becomes an `add`
54
+ * - `protected-entry` gained / lost: `added` / `removed` that the entry covers (§12 Matching), under `--surface protected`
55
+ * - `member-data`: `added`, `removed`, and `changed` (`locks` or `effect`)
56
+ * - `holder` added, dropped or re-chained: `added` / `removed` of targets named on it, shadowing through it, or reached
57
+ * through it (on the holder its prototype was or becomes)
58
+ * - `immutable-prototype`: `added` / `removed` of its `prototype-change`
59
+ * - `framework-path-rule`: `changed` (`attribution`), and a `rekeyed` pair, where an occurrence was named by the rule
60
+ * or either side's key is a package the rule names
61
+ * - `url-normalization`: `changed` (`attribution`) between `url:` keys, and an `added` / `removed` pair: an `added` and
62
+ * a `removed` finding of the same target and effect, both with `url:` keys
63
+ */
64
+ export declare function withExplainedBy<F extends Explainable>(findings: readonly F[], input: {
65
+ catalog: SerializedCatalog;
66
+ changelog: CatalogChangelog;
67
+ /** The baseline's catalog version, or null without a baseline. */
68
+ baselineCatalogVersion: number | null;
69
+ surface: 'protected' | 'all';
70
+ }): (F & {
71
+ explainedBy?: ExplainingRecord[];
72
+ })[];
73
+ export {};
@@ -0,0 +1,224 @@
1
+ // What the catalog says about each compared finding (§4 Catalog versions): `catalogStatus` for a retained target, and
2
+ // `explainedBy`, the catalog changelog's records that could explain a delta.
3
+ import { protectedSurface } from "../../findings/index.js";
4
+ /**
5
+ * Sets `catalogStatus: retained` on each finding whose target, or the member it shadows, is a retained target of the
6
+ * catalog. It is information only.
7
+ */
8
+ export function withCatalogStatus(findings, catalog) {
9
+ return findings.map((finding) => {
10
+ const { realm, name, shadows } = finding.target;
11
+ const retained = [name, shadows].some((member) => member !== undefined && catalog.targets[realm][member]?.catalogStatus === 'retained');
12
+ return retained ? { ...finding, catalogStatus: 'retained' } : finding;
13
+ });
14
+ }
15
+ /**
16
+ * Sets `explainedBy` (§4 Catalog versions) on each delta that a change record between the baseline's catalog version
17
+ * and the scan's could explain, with those records in changelog order. Only `added`, `removed` and `changed` findings,
18
+ * and `existing` ones with a `rekeyed` note, are deltas: never `unconfirmed` or plain `existing`. A baseline from the
19
+ * same or a newer catalog version gives none.
20
+ *
21
+ * A record is about a delta when it names the delta's target, the member the target shadows on the current side (its
22
+ * `shadows`) or the baseline side (worked out from the current catalog, with the record's own targets, since a
23
+ * `removed` entry stores none), or a wildcard's holder. Then, by the §4 table:
24
+ * - `target` added / dropped: `added` / `removed`, and `changed` (`effect`) where the finding was or becomes an `add`
25
+ * - `protected-entry` gained / lost: `added` / `removed` that the entry covers (§12 Matching), under `--surface protected`
26
+ * - `member-data`: `added`, `removed`, and `changed` (`locks` or `effect`)
27
+ * - `holder` added, dropped or re-chained: `added` / `removed` of targets named on it, shadowing through it, or reached
28
+ * through it (on the holder its prototype was or becomes)
29
+ * - `immutable-prototype`: `added` / `removed` of its `prototype-change`
30
+ * - `framework-path-rule`: `changed` (`attribution`), and a `rekeyed` pair, where an occurrence was named by the rule
31
+ * or either side's key is a package the rule names
32
+ * - `url-normalization`: `changed` (`attribution`) between `url:` keys, and an `added` / `removed` pair: an `added` and
33
+ * a `removed` finding of the same target and effect, both with `url:` keys
34
+ */
35
+ export function withExplainedBy(findings, input) {
36
+ const { catalog, changelog, baselineCatalogVersion: since } = input;
37
+ if (since === null)
38
+ return [...findings];
39
+ const current = catalog.metadata.catalogVersion;
40
+ const records = changelog.versions
41
+ .filter(({ catalogVersion }) => catalogVersion > since && catalogVersion <= current)
42
+ .flatMap(({ catalogVersion, changes }) => changes.map((change) => ({ catalogVersion, ...change })));
43
+ if (records.length === 0)
44
+ return [...findings];
45
+ const context = explainContext(catalog, records, input.surface, findings);
46
+ return findings.map((finding) => {
47
+ const delta = deltaOf(finding);
48
+ if (delta === undefined)
49
+ return finding;
50
+ const explainedBy = records.filter((record) => explains(record, finding, delta, context));
51
+ return explainedBy.length === 0 ? finding : { ...finding, explainedBy };
52
+ });
53
+ }
54
+ function deltaOf(finding) {
55
+ const { baselineState: state } = finding;
56
+ const rekeyed = finding.notes.some(({ kind }) => kind === 'rekeyed');
57
+ if (state === 'added' || state === 'removed' || state === 'changed' || (state === 'existing' && rekeyed)) {
58
+ return { state, reasons: new Set(finding.changedReasons.map(({ reason }) => reason)), rekeyed };
59
+ }
60
+ return undefined;
61
+ }
62
+ /** A finding's realm, target, effect and baseline state, which an `added` / `removed` pair matches on. */
63
+ function pairKey({ target, effect }, state) {
64
+ return JSON.stringify([target.realm, target.name, effect, state]);
65
+ }
66
+ function explainContext(catalog, records, surface, findings) {
67
+ const recordTargets = new Map();
68
+ const droppedHolders = new Map();
69
+ const add = (map, realm, name) => {
70
+ let names = map.get(realm);
71
+ if (names === undefined)
72
+ map.set(realm, (names = new Set()));
73
+ names.add(name);
74
+ };
75
+ const covers = new Map();
76
+ for (const record of records) {
77
+ if (record.kind === 'target')
78
+ add(recordTargets, record.realm, record.name);
79
+ if (record.kind === 'holder' && record.change === 'dropped')
80
+ add(droppedHolders, record.realm, record.holder);
81
+ if (record.kind === 'protected-entry') {
82
+ covers.set(record, protectedSurface({ ...catalog, protectedSurface: { [record.surface]: [record.entry] } }));
83
+ }
84
+ }
85
+ const urlDeltas = new Set(findings
86
+ .filter(({ baselineState: state, attribution }) => (state === 'added' || state === 'removed') && attribution.key.startsWith('url:'))
87
+ .map((finding) => pairKey(finding, finding.baselineState)));
88
+ return { catalog, recordTargets, droppedHolders, surface, covers, urlDeltas };
89
+ }
90
+ /** The holder a target name is on in the current catalog, or on a holder the records drop. */
91
+ function holderIn(name, realm, context) {
92
+ const holders = context.catalog.holders[realm];
93
+ const dropped = context.droppedHolders.get(realm);
94
+ const known = (holder) => Object.hasOwn(holders, holder) || dropped?.has(holder) === true;
95
+ const bracket = name.indexOf('["');
96
+ if (bracket >= 0)
97
+ return known(name.slice(0, bracket)) ? name.slice(0, bracket) : undefined;
98
+ for (let dot = name.lastIndexOf('.'); dot > 0; dot = name.lastIndexOf('.', dot - 1)) {
99
+ if (known(name.slice(0, dot)))
100
+ return name.slice(0, dot);
101
+ }
102
+ return undefined;
103
+ }
104
+ /**
105
+ * The catalog members a finding's target is about: its name, its `shadows`, and the member it shadows worked out from
106
+ * the current catalog: the first holder up its chain whose member of that key the catalog lists or a record adds or
107
+ * drops. A wildcard is about no member.
108
+ */
109
+ function membersOf({ realm, name, shadows }, context) {
110
+ if (name.endsWith('*'))
111
+ return [];
112
+ const members = [name, ...(shadows === undefined ? [] : [shadows])];
113
+ const holder = holderIn(name, realm, context);
114
+ if (holder === undefined)
115
+ return members;
116
+ const key = name.slice(holder.length);
117
+ const holders = context.catalog.holders[realm];
118
+ const recorded = context.recordTargets.get(realm);
119
+ for (let at = holders[holder]?.proto ?? null; at !== null; at = holders[at]?.proto ?? null) {
120
+ const member = at + key;
121
+ if (recorded?.has(member) === true || Object.hasOwn(context.catalog.targets[realm], member)) {
122
+ if (!members.includes(member))
123
+ members.push(member);
124
+ break;
125
+ }
126
+ }
127
+ return members;
128
+ }
129
+ /** The holder of a wildcard target (`<holder>.*`, `<holder>.<prefix>*`), or undefined for any other target. */
130
+ function wildcardHolder({ realm, name }, context) {
131
+ return name.endsWith('*') ? holderIn(name, realm, context) : undefined;
132
+ }
133
+ function explains(record, finding, delta, context) {
134
+ const { state, reasons } = delta;
135
+ const addOrRemove = state === 'added' || state === 'removed';
136
+ const { target } = finding;
137
+ switch (record.kind) {
138
+ case 'target': {
139
+ if (record.realm !== target.realm || !touches(record.name, finding, context))
140
+ return false;
141
+ if (state === (record.change === 'added' ? 'added' : 'removed'))
142
+ return true;
143
+ const effect = finding.changedReasons.find(({ reason }) => reason === 'effect');
144
+ return state === 'changed' && effect !== undefined && (effect.before === 'add' || effect.after === 'add');
145
+ }
146
+ case 'protected-entry': {
147
+ if (context.surface !== 'protected' || state !== (record.change === 'gained' ? 'added' : 'removed'))
148
+ return false;
149
+ const covers = context.covers.get(record);
150
+ const shadows = membersOf(target, context).slice(1);
151
+ return [undefined, ...shadows].some((shadow) => covers({ realm: target.realm, name: target.name, ...(shadow === undefined ? {} : { shadows: shadow }) }, finding.effect));
152
+ }
153
+ case 'member-data':
154
+ return (record.realm === target.realm &&
155
+ touches(record.name, finding, context) &&
156
+ (addOrRemove || (state === 'changed' && (reasons.has('locks') || reasons.has('effect')))));
157
+ case 'holder': {
158
+ if (record.realm !== target.realm || !addOrRemove)
159
+ return false;
160
+ const neighbours = [record.before?.proto, record.after?.proto].filter((proto) => typeof proto === 'string');
161
+ const wildcard = wildcardHolder(target, context);
162
+ if (wildcard !== undefined)
163
+ return wildcard === record.holder;
164
+ const members = membersOf(target, context);
165
+ const holders = members.map((member) => holderIn(member, target.realm, context));
166
+ if (holders.some((holder) => holder === record.holder || neighbours.includes(holder)))
167
+ return true;
168
+ return throughChain(holders[0], holders.slice(1), record.holder, target.realm, context);
169
+ }
170
+ case 'immutable-prototype':
171
+ return (record.realm === target.realm &&
172
+ addOrRemove &&
173
+ finding.effect === 'prototype-change' &&
174
+ target.name === `${record.holder}.[[Prototype]]`);
175
+ case 'framework-path-rule': {
176
+ if (!((state === 'changed' && reasons.has('attribution')) || delta.rekeyed))
177
+ return false;
178
+ if (finding.occurrences.some(({ evidence }) => evidence.pathRule === record.id))
179
+ return true;
180
+ const packages = [record.before?.package, record.after?.package].filter((name) => name !== undefined);
181
+ return keysOf(finding).some((key) => packages.some((name) => packagePattern(name).test(key)));
182
+ }
183
+ case 'url-normalization':
184
+ if (addOrRemove) {
185
+ const other = state === 'added' ? 'removed' : 'added';
186
+ return finding.attribution.key.startsWith('url:') && context.urlDeltas.has(pairKey(finding, other));
187
+ }
188
+ return state === 'changed' && reasons.has('attribution') && keysOf(finding).some((key) => key.startsWith('url:'));
189
+ }
190
+ }
191
+ /** Whether a record's target is one the finding is about, or a wildcard finding's holder is the target's holder. */
192
+ function touches(name, finding, context) {
193
+ const wildcard = wildcardHolder(finding.target, context);
194
+ if (wildcard !== undefined)
195
+ return holderIn(name, finding.target.realm, context) === wildcard;
196
+ return membersOf(finding.target, context).includes(name);
197
+ }
198
+ /** Whether `holder` is on the chain from `from` up to any of `to`, the holders of the members the target shadows. */
199
+ function throughChain(from, to, holder, realm, context) {
200
+ if (from === undefined)
201
+ return false;
202
+ const holders = context.catalog.holders[realm];
203
+ for (let at = holders[from]?.proto ?? null; at !== null && !to.includes(at); at = holders[at]?.proto ?? null) {
204
+ if (at === holder)
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ /** The attribution keys a finding had and has: its own, a `changed` (`attribution`) before, and a `rekeyed` before. */
210
+ function keysOf(finding) {
211
+ const keys = [finding.attribution.key];
212
+ for (const { reason, before } of finding.changedReasons)
213
+ if (reason === 'attribution')
214
+ keys.push(String(before));
215
+ for (const { kind, before } of finding.notes)
216
+ if (kind === 'rekeyed' && Array.isArray(before))
217
+ keys.push(...before.map(String));
218
+ return keys;
219
+ }
220
+ /** The `pkg:` keys of the package a framework path rule names, where `<name>` stands for any captured value. */
221
+ function packagePattern(template) {
222
+ const parts = template.split(/<[^>]+>/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
223
+ return new RegExp(`^pkg:${parts.join('[^/]+')}$`);
224
+ }