dependency-radar 0.8.0 → 0.9.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/dist/cli.js CHANGED
@@ -13,9 +13,11 @@ const importGraphRunner_1 = require("./runners/importGraphRunner");
13
13
  const npmAudit_1 = require("./runners/npmAudit");
14
14
  const npmLs_1 = require("./runners/npmLs");
15
15
  const npmOutdated_1 = require("./runners/npmOutdated");
16
+ const npmRegistryMetadata_1 = require("./runners/npmRegistryMetadata");
16
17
  const lockfileSignals_1 = require("./runners/lockfileSignals");
17
18
  const report_1 = require("./report");
18
19
  const compare_1 = require("./compare");
20
+ const findings_1 = require("./findings");
19
21
  const outputFormats_1 = require("./outputFormats");
20
22
  const why_1 = require("./why");
21
23
  const schema_1 = require("./schema");
@@ -935,7 +937,7 @@ function buildCombinedDependencyGraph(rootPath, packageMetas, dependencyGraphs)
935
937
  * --project, --quiet, --out, --keep-temp, --offline, --json, --format, --sbom, --target-node,
936
938
  * --audit-signatures, --schema, --timestamp, --open, --no-report, --fail-on, --help / -h.
937
939
  *
938
- * The --offline flag disables both audit and outdated checks. Unknown options or unexpected positional
940
+ * The --offline flag disables registry-backed checks. Unknown options or unexpected positional
939
941
  * arguments cause the process to exit with an error.
940
942
  *
941
943
  * @param argv - Array of CLI tokens (typically process.argv.slice(2))
@@ -1175,12 +1177,20 @@ Options:
1175
1177
  --timestamp Add a local timestamp to generated report filenames
1176
1178
  --no-report Do not write HTML/JSON report files or temp artifacts to disk
1177
1179
  --keep-temp Keep .dependency-radar folder
1178
- --offline Skip npm audit and npm outdated (useful for offline scans)
1180
+ --offline Skip registry-backed checks (audit, outdated, signatures, targeted registry enrichment)
1179
1181
  --open Open the generated report using the system default application
1180
1182
  --fail-on <rules> Fail with exit code 1 when selected rules are violated
1181
- Supported: reachable-vuln, production-vuln, high-severity-vuln,
1182
- licence-mismatch, copyleft-detected, unknown-licence,
1183
- supply-chain-source
1183
+ Scan rules: reachable-vuln, production-vuln, high-severity-vuln,
1184
+ licence-mismatch, copyleft-detected, unknown-licence,
1185
+ supply-chain-source
1186
+ Compare rules: new-supply-chain-signal, new-install-script,
1187
+ new-native-binding, new-bin, new-direct-dependency,
1188
+ new-child-process, new-network-access, new-env-access,
1189
+ new-home-access, new-ssh-usage, new-obfuscation-signal,
1190
+ new-bundled-dependencies, new-shrinkwrap,
1191
+ new-recent-package, new-recent-version,
1192
+ new-low-release-history, new-reactivated-package,
1193
+ new-old-major-patch
1184
1194
 
1185
1195
  \`explain\` reuses the same local scan model and prints a terminal view for one package.
1186
1196
  \`why\` prints shortest dependency paths for one package.
@@ -1359,6 +1369,9 @@ function printPolicyViolations(violations) {
1359
1369
  console.log(colorLeadingSymbol("✖ Policy violations detected:"));
1360
1370
  for (const violation of violations) {
1361
1371
  console.log(`- ${violation.message}`);
1372
+ for (const detail of violation.details || []) {
1373
+ console.log(` - ${detail}`);
1374
+ }
1362
1375
  }
1363
1376
  }
1364
1377
  /**
@@ -1750,6 +1763,28 @@ async function executeAnalysis(opts, options) {
1750
1763
  ...(toolVersions ? { toolVersions } : {}),
1751
1764
  ...(typeof opts.targetNodeMajor === "number" ? { targetNodeMajor: opts.targetNodeMajor } : {}),
1752
1765
  });
1766
+ if (opts.outdated) {
1767
+ let registryEnrichment = { attempted: 0, succeeded: 0 };
1768
+ try {
1769
+ registryEnrichment = await (0, npmRegistryMetadata_1.enrichAggregatedWithRegistryMetadata)(aggregated, {
1770
+ offline: false,
1771
+ });
1772
+ }
1773
+ catch (err) {
1774
+ if (!opts.quiet) {
1775
+ const message = err instanceof Error ? err.message : String(err);
1776
+ spinner.log(statusLine("⚠", `Targeted registry metadata unavailable (${message})`));
1777
+ }
1778
+ }
1779
+ if (!opts.quiet && registryEnrichment.succeeded > 0) {
1780
+ spinner.log(statusLine("✔", `Targeted registry metadata collected for ${registryEnrichment.succeeded} suspicious package${registryEnrichment.succeeded === 1 ? "" : "s"}`));
1781
+ }
1782
+ if (registryEnrichment.attempted > 0) {
1783
+ const findings = (0, findings_1.buildDependencyFindings)(aggregated, { targetNodeMajor: opts.targetNodeMajor });
1784
+ aggregated.findings = findings;
1785
+ aggregated.summary.findingCount = findings.length;
1786
+ }
1787
+ }
1753
1788
  dependencyCount = Object.keys(aggregated.dependencies).length;
1754
1789
  const importGraphComplete = perPackageImportGraph.every((result) => result.ok);
1755
1790
  const summary = buildCliSummary(aggregated, {
@@ -1903,6 +1938,16 @@ async function runWhyCommand(opts) {
1903
1938
  process.exit(1);
1904
1939
  }
1905
1940
  }
1941
+ /**
1942
+ * Compare the current analysis against a previous report and print the comparison and policy violations.
1943
+ *
1944
+ * Validates the `opts.comparePath` report file against the expected schema, runs a new analysis without
1945
+ * writing artifacts, computes a diff between the previous and current aggregated results, prints the
1946
+ * formatted comparison, prints any policy violations (including compare-specific violations), and exits
1947
+ * with code 1 when validation fails or any policy violations are present.
1948
+ *
1949
+ * @param opts - CLI options controlling the comparison run (must include `comparePath` and may include `failOn`)
1950
+ */
1906
1951
  async function runCompareCommand(opts) {
1907
1952
  var _a;
1908
1953
  const previousPath = (_a = opts.comparePath) === null || _a === void 0 ? void 0 : _a.trim();
@@ -1938,8 +1983,17 @@ async function runCompareCommand(opts) {
1938
1983
  emitArtifactSummary: false,
1939
1984
  emitWorkspacePackageSummary: false,
1940
1985
  });
1986
+ const comparison = (0, compare_1.compareReports)(previous, result.aggregated);
1987
+ const policyViolations = [
1988
+ ...result.policyViolations,
1989
+ ...(0, failOn_1.evaluateComparePolicyViolations)(previous, result.aggregated, opts.failOn),
1990
+ ];
1941
1991
  console.log("");
1942
- console.log((0, compare_1.formatCompareOutput)((0, compare_1.compareReports)(previous, result.aggregated)));
1992
+ console.log((0, compare_1.formatCompareOutput)(comparison));
1993
+ printPolicyViolations(policyViolations);
1994
+ if (policyViolations.length > 0) {
1995
+ process.exit(1);
1996
+ }
1943
1997
  }
1944
1998
  /**
1945
1999
  * Run the CLI entrypoint and dispatch to the selected command.
package/dist/explain.js CHANGED
@@ -9,6 +9,34 @@ const BLOCKER_LABELS = {
9
9
  installScripts: 'Install lifecycle scripts',
10
10
  deprecated: 'Deprecated by author',
11
11
  };
12
+ const EXECUTION_SIGNAL_LABELS = {
13
+ 'network-access': 'network access',
14
+ 'dynamic-exec': 'dynamic execution',
15
+ 'child-process': 'child process APIs',
16
+ encoding: 'encoding/decoding logic',
17
+ obfuscated: 'obfuscation-like code shape',
18
+ 'reads-env': 'environment access',
19
+ 'reads-home': 'home directory access',
20
+ 'uses-ssh': 'SSH-related references'
21
+ };
22
+ const PACKAGING_SIGNAL_LABELS = {
23
+ 'bundled-dependencies': 'bundled dependencies',
24
+ 'embedded-shrinkwrap': 'embedded npm-shrinkwrap.json'
25
+ };
26
+ const REGISTRY_SIGNAL_LABELS = {
27
+ 'recent-package': 'recently created package',
28
+ 'recent-version': 'recently published installed version',
29
+ 'low-release-history': 'low release history',
30
+ 'reactivated-package': 'reactivated after dormancy',
31
+ 'old-major-new-patch': 'recent patch on older major line'
32
+ };
33
+ /**
34
+ * Locate dependency records for a given package name and return them in prioritized order.
35
+ *
36
+ * @param aggregated - The aggregated dataset containing dependency records
37
+ * @param packageName - The package name to search for
38
+ * @returns An array of matching `DependencyRecord` objects sorted with direct dependencies first, then by descending package version; empty if none found
39
+ */
12
40
  function findDependenciesByPackageName(aggregated, packageName) {
13
41
  return Object.values(aggregated.dependencies || {})
14
42
  .filter((dep) => dep.package.name === packageName)
@@ -19,8 +47,17 @@ function findDependenciesByPackageName(aggregated, packageName) {
19
47
  return compareVersionStrings(b.package.version, a.package.version);
20
48
  });
21
49
  }
50
+ /**
51
+ * Generate a human-readable, line-oriented explanation report for a package
52
+ * across the provided dependency records.
53
+ *
54
+ * @param packageName - The package name shown in the report header
55
+ * @param matches - DependencyRecord entries for the package to render as individual sections
56
+ * @param context - Rendering context controlling audit availability and import-graph completeness
57
+ * @returns The formatted multi-section report as a string. If `matches` is empty the string is `✖ Package not found: ${packageName}`.
58
+ */
22
59
  function formatExplainOutput(packageName, matches, context) {
23
- var _a, _b, _c, _d;
60
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
24
61
  if (matches.length === 0) {
25
62
  return `✖ Package not found: ${packageName}`;
26
63
  }
@@ -89,6 +126,51 @@ function formatExplainOutput(packageName, matches, context) {
89
126
  else {
90
127
  lines.push(' none');
91
128
  }
129
+ lines.push('');
130
+ lines.push('Local execution signals:');
131
+ const scriptSignals = new Set(((_f = (_e = dep.execution) === null || _e === void 0 ? void 0 : _e.scripts) === null || _f === void 0 ? void 0 : _f.signals) || []);
132
+ const localSignals = (((_g = dep.execution) === null || _g === void 0 ? void 0 : _g.signals) || []).filter((signal) => !scriptSignals.has(signal));
133
+ if (localSignals.length) {
134
+ for (const signal of localSignals) {
135
+ lines.push(` - ${signal} (${EXECUTION_SIGNAL_LABELS[signal] || 'review signal'})`);
136
+ }
137
+ }
138
+ else {
139
+ lines.push(' none');
140
+ }
141
+ lines.push('');
142
+ lines.push('Packaging signals:');
143
+ if ((_j = (_h = dep.packaging) === null || _h === void 0 ? void 0 : _h.signals) === null || _j === void 0 ? void 0 : _j.length) {
144
+ for (const signal of dep.packaging.signals) {
145
+ lines.push(` - ${signal} (${PACKAGING_SIGNAL_LABELS[signal] || 'review signal'})`);
146
+ }
147
+ if ((_k = dep.packaging.bundledDependencies) === null || _k === void 0 ? void 0 : _k.length) {
148
+ lines.push(` bundled dependencies: ${dep.packaging.bundledDependencies.join(', ')}`);
149
+ }
150
+ }
151
+ else {
152
+ lines.push(' none');
153
+ }
154
+ lines.push('');
155
+ lines.push('Registry metadata signals:');
156
+ const registry = (_l = dep.supplyChain) === null || _l === void 0 ? void 0 : _l.registry;
157
+ if ((_m = registry === null || registry === void 0 ? void 0 : registry.signals) === null || _m === void 0 ? void 0 : _m.length) {
158
+ for (const signal of registry.signals) {
159
+ lines.push(` - ${signal} (${REGISTRY_SIGNAL_LABELS[signal] || 'review signal'})`);
160
+ }
161
+ if (registry.installedVersionPublishedAt) {
162
+ lines.push(` installed version published: ${registry.installedVersionPublishedAt}`);
163
+ }
164
+ if (typeof registry.versionCount === 'number') {
165
+ lines.push(` version count: ${registry.versionCount}`);
166
+ }
167
+ }
168
+ else if ((registry === null || registry === void 0 ? void 0 : registry.attempted) && !registry.ok) {
169
+ lines.push(` unavailable (${registry.error || 'lookup failed'})`);
170
+ }
171
+ else {
172
+ lines.push(' none');
173
+ }
92
174
  if (otherVersions.length > 0) {
93
175
  lines.push('');
94
176
  lines.push('Other detected versions:');
package/dist/failOn.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SUPPORTED_FAIL_ON_RULES = void 0;
4
4
  exports.parseFailOnRules = parseFailOnRules;
5
5
  exports.evaluatePolicyViolations = evaluatePolicyViolations;
6
+ exports.evaluateComparePolicyViolations = evaluateComparePolicyViolations;
6
7
  const license_1 = require("./license");
7
8
  exports.SUPPORTED_FAIL_ON_RULES = [
8
9
  'reachable-vuln',
@@ -11,7 +12,25 @@ exports.SUPPORTED_FAIL_ON_RULES = [
11
12
  'licence-mismatch',
12
13
  'copyleft-detected',
13
14
  'unknown-licence',
14
- 'supply-chain-source'
15
+ 'supply-chain-source',
16
+ 'new-supply-chain-signal',
17
+ 'new-install-script',
18
+ 'new-native-binding',
19
+ 'new-bin',
20
+ 'new-direct-dependency',
21
+ 'new-child-process',
22
+ 'new-network-access',
23
+ 'new-env-access',
24
+ 'new-home-access',
25
+ 'new-ssh-usage',
26
+ 'new-obfuscation-signal',
27
+ 'new-bundled-dependencies',
28
+ 'new-shrinkwrap',
29
+ 'new-recent-package',
30
+ 'new-recent-version',
31
+ 'new-low-release-history',
32
+ 'new-reactivated-package',
33
+ 'new-old-major-patch'
15
34
  ];
16
35
  const SUPPORTED_FAIL_ON_RULE_SET = new Set(exports.SUPPORTED_FAIL_ON_RULES);
17
36
  /**
@@ -37,6 +56,230 @@ function vulnerabilityCount(dep) {
37
56
  (dep.security.summary.moderate || 0) +
38
57
  (dep.security.summary.low || 0));
39
58
  }
59
+ /**
60
+ * Group dependency records by their package name.
61
+ *
62
+ * @param deps - Optional record of dependency entries keyed by dependency id; entries missing a package name are ignored
63
+ * @returns A Map whose keys are package names and whose values are arrays of `DependencyRecord` objects for that package
64
+ */
65
+ function byPackageName(deps) {
66
+ var _a;
67
+ const map = new Map();
68
+ for (const dep of Object.values(deps || {})) {
69
+ const name = (_a = dep.package) === null || _a === void 0 ? void 0 : _a.name;
70
+ if (!name)
71
+ continue;
72
+ const entries = map.get(name) || [];
73
+ entries.push(dep);
74
+ map.set(name, entries);
75
+ }
76
+ return map;
77
+ }
78
+ /**
79
+ * Produce a package label for a dependency using its package id when available, otherwise `name@version`.
80
+ *
81
+ * @param dep - The dependency record whose package will be formatted
82
+ * @returns The package `id` if present; otherwise the package `name` and `version` joined with `@`
83
+ */
84
+ function formatPackage(dep) {
85
+ return dep.package.id || `${dep.package.name}@${dep.package.version}`;
86
+ }
87
+ /**
88
+ * Retrieve the package's execution script hook names sorted lexicographically.
89
+ *
90
+ * @param dep - Dependency record to read hooks from
91
+ * @returns The hook names from `dep.execution?.scripts?.hooks` sorted lexicographically; an empty array if no hooks are present
92
+ */
93
+ function sortedHooks(dep) {
94
+ var _a, _b;
95
+ return [...(((_b = (_a = dep.execution) === null || _a === void 0 ? void 0 : _a.scripts) === null || _b === void 0 ? void 0 : _b.hooks) || [])].sort();
96
+ }
97
+ /**
98
+ * Collect execution-related signals from a dependency record.
99
+ *
100
+ * @param dep - The dependency record to inspect for execution signals
101
+ * @returns A Set of unique `ExecutionSignal` values found on the dependency
102
+ */
103
+ function executionSignals(dep) {
104
+ var _a, _b, _c;
105
+ return new Set([
106
+ ...(((_a = dep.execution) === null || _a === void 0 ? void 0 : _a.signals) || []),
107
+ ...(((_c = (_b = dep.execution) === null || _b === void 0 ? void 0 : _b.scripts) === null || _c === void 0 ? void 0 : _c.signals) || [])
108
+ ]);
109
+ }
110
+ /**
111
+ * Collects packaging signals present on a dependency.
112
+ *
113
+ * @param dep - The dependency record to inspect
114
+ * @returns A Set of `PackagingSignal` values found on `dep`; empty if the dependency has no packaging signals
115
+ */
116
+ function packagingSignals(dep) {
117
+ var _a;
118
+ return new Set(((_a = dep.packaging) === null || _a === void 0 ? void 0 : _a.signals) || []);
119
+ }
120
+ /**
121
+ * Get the registry risk signals associated with a dependency.
122
+ *
123
+ * @param dep - The dependency record to inspect
124
+ * @returns A Set of `RegistryRiskSignal` values found on the dependency's `supplyChain.registry.signals`; an empty `Set` if none are present
125
+ */
126
+ function registrySignals(dep) {
127
+ var _a, _b;
128
+ return new Set(((_b = (_a = dep.supplyChain) === null || _a === void 0 ? void 0 : _a.registry) === null || _b === void 0 ? void 0 : _b.signals) || []);
129
+ }
130
+ /**
131
+ * Extracts the package name associated with a supply-chain signal.
132
+ *
133
+ * Prefers `signal.packageName` when present; otherwise derives the name from
134
+ * `signal.packageId` by taking the substring before the last `@`. Returns
135
+ * `undefined` if neither value yields a usable package name.
136
+ *
137
+ * @param signal - The supply-chain signal to inspect
138
+ * @returns The package name if present or derivable, `undefined` otherwise
139
+ */
140
+ function signalPackageName(signal) {
141
+ if (signal.packageName)
142
+ return signal.packageName;
143
+ if (!signal.packageId)
144
+ return undefined;
145
+ const at = signal.packageId.lastIndexOf('@');
146
+ if (at <= 0)
147
+ return undefined;
148
+ return signal.packageId.slice(0, at);
149
+ }
150
+ /**
151
+ * Produce a human-readable package label for a supply-chain signal.
152
+ *
153
+ * @param signal - The supply-chain signal object used to derive the label.
154
+ * @returns The label chosen in this priority: `signal.packageId`, `packageName@packageVersion`, `packageName`, or `'lockfile'` when no package information is available.
155
+ */
156
+ function signalPackageLabel(signal) {
157
+ if (signal.packageId)
158
+ return signal.packageId;
159
+ if (signal.packageName && signal.packageVersion)
160
+ return `${signal.packageName}@${signal.packageVersion}`;
161
+ if (signal.packageName)
162
+ return signal.packageName;
163
+ return 'lockfile';
164
+ }
165
+ /**
166
+ * Produce sets of observed supply-chain signal types, both per-package and globally.
167
+ *
168
+ * @param signals - Array of supply-chain signals to analyze; may be `undefined` or empty.
169
+ * @returns An object with:
170
+ * - `byPackage`: map from package name to a `Set` of signal `type` strings observed for that package.
171
+ * - `global`: `Set` of all signal `type` strings observed (including those not tied to a package).
172
+ */
173
+ function supplyChainSignalKeys(signals) {
174
+ const byPackage = new Map();
175
+ const global = new Set();
176
+ for (const signal of signals || []) {
177
+ global.add(signal.type);
178
+ const name = signalPackageName(signal);
179
+ if (!name)
180
+ continue;
181
+ const types = byPackage.get(name) || new Set();
182
+ types.add(signal.type);
183
+ byPackage.set(name, types);
184
+ }
185
+ return { byPackage, global };
186
+ }
187
+ /**
188
+ * Appends a PolicyViolation for dependencies that newly exhibit the specified execution signal.
189
+ *
190
+ * If the provided `rule` is present in `rules` and one or more entries in `currentDeps`
191
+ * have the `signal` while no previous dependency with the same package name had it,
192
+ * a `PolicyViolation` describing the new execution signal(s) is pushed onto `violations`.
193
+ *
194
+ * @param violations - Mutable array to receive the generated PolicyViolation when matches are found
195
+ * @param previousByName - Map of package name to previous DependencyRecord[] used as the baseline for comparison
196
+ * @param currentDeps - Current dependency records to evaluate for newly introduced signals
197
+ * @param rules - Set of enabled fail-on rules; the function is a no-op if `rule` is not in this set
198
+ * @param rule - The specific FailOnRule to produce a violation for when triggered
199
+ * @param signal - The ExecutionSignal to detect as newly introduced
200
+ */
201
+ function pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, rule, signal) {
202
+ if (!rules.has(rule))
203
+ return;
204
+ const details = currentDeps
205
+ .filter((dep) => {
206
+ if (!executionSignals(dep).has(signal))
207
+ return false;
208
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => executionSignals(previousDep).has(signal));
209
+ })
210
+ .map((dep) => `${formatPackage(dep)} introduced execution signal: ${signal}`)
211
+ .sort();
212
+ if (details.length === 0)
213
+ return;
214
+ violations.push({
215
+ rule,
216
+ count: details.length,
217
+ message: `${details.length} new execution ${pluralize(details.length, 'signal', 'signals')}: ${signal}`,
218
+ details
219
+ });
220
+ }
221
+ /**
222
+ * Add a policy violation to `violations` when `signal` appears in current dependencies but was not present for the same package name in `previousByName`, and the given `rule` is enabled.
223
+ *
224
+ * @param violations - Accumulates discovered PolicyViolation objects; this function may push a new violation into it.
225
+ * @param previousByName - Map from package name to previous scan DependencyRecord[] used to determine whether `signal` was already present for a package.
226
+ * @param currentDeps - Current scan dependencies to inspect for newly introduced packaging signals.
227
+ * @param rules - Selected fail-on rules; the function no-ops if `rule` is not in this set.
228
+ * @param rule - The specific packaging-related compare-mode rule to enforce (e.g., `new-shrinkwrap` or `new-bundled-dependencies`).
229
+ * @param signal - The PackagingSignal type to detect as newly introduced.
230
+ */
231
+ function pushNewPackagingSignalViolation(violations, previousByName, currentDeps, rules, rule, signal) {
232
+ if (!rules.has(rule))
233
+ return;
234
+ const details = currentDeps
235
+ .filter((dep) => {
236
+ if (!packagingSignals(dep).has(signal))
237
+ return false;
238
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => packagingSignals(previousDep).has(signal));
239
+ })
240
+ .map((dep) => `${formatPackage(dep)} introduced packaging signal: ${signal}`)
241
+ .sort();
242
+ if (details.length === 0)
243
+ return;
244
+ violations.push({
245
+ rule,
246
+ count: details.length,
247
+ message: `${details.length} new packaging ${pluralize(details.length, 'signal', 'signals')}: ${signal}`,
248
+ details
249
+ });
250
+ }
251
+ /**
252
+ * Append a PolicyViolation for registry risk signals that appear in the current scan but were not present previously.
253
+ *
254
+ * If `rule` is not enabled in `rules`, this function does nothing. When enabled, it identifies current dependencies whose registry signals include `signal` and for which no previous dependency with the same package name had that signal, then pushes a violation with the total `count`, a summary `message`, and `details` listing affected packages.
255
+ *
256
+ * @param violations - Array to which the resulting PolicyViolation will be pushed
257
+ * @param previousByName - Map of package name to list of previous DependencyRecord entries
258
+ * @param currentDeps - List of current DependencyRecord entries to inspect
259
+ * @param rules - Set of enabled fail-on rules
260
+ * @param rule - The specific fail-on rule to check (must be present in `rules` to produce a violation)
261
+ * @param signal - The registry risk signal to detect as newly introduced
262
+ */
263
+ function pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, rule, signal) {
264
+ if (!rules.has(rule))
265
+ return;
266
+ const details = currentDeps
267
+ .filter((dep) => {
268
+ if (!registrySignals(dep).has(signal))
269
+ return false;
270
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => registrySignals(previousDep).has(signal));
271
+ })
272
+ .map((dep) => `${formatPackage(dep)} introduced registry risk signal: ${signal}`)
273
+ .sort();
274
+ if (details.length === 0)
275
+ return;
276
+ violations.push({
277
+ rule,
278
+ count: details.length,
279
+ message: `${details.length} new registry ${pluralize(details.length, 'risk signal', 'risk signals')}: ${signal}`,
280
+ details
281
+ });
282
+ }
40
283
  /**
41
284
  * Detects whether a dependency has a strong copyleft license.
42
285
  *
@@ -189,3 +432,129 @@ function evaluatePolicyViolations(aggregated, rules) {
189
432
  }
190
433
  return violations;
191
434
  }
435
+ /**
436
+ * Compute compare-mode policy violations that focus on newly introduced risky traits.
437
+ *
438
+ * Delta rules compare the current scan against a previous Dependency Radar JSON report. They only fire
439
+ * when a targeted trait appears in the current report and the baseline did not show that trait for the
440
+ * same package name, or did not show that supply-chain signal type at all when a signal cannot be tied to
441
+ * a package.
442
+ */
443
+ function evaluateComparePolicyViolations(previous, current, rules) {
444
+ var _a, _b;
445
+ if (rules.size === 0)
446
+ return [];
447
+ const previousByName = byPackageName(previous.dependencies);
448
+ const currentDeps = Object.values(current.dependencies || {});
449
+ const violations = [];
450
+ if (rules.has('new-supply-chain-signal')) {
451
+ const previousSignals = supplyChainSignalKeys((_a = previous.supplyChain) === null || _a === void 0 ? void 0 : _a.signals);
452
+ const details = (((_b = current.supplyChain) === null || _b === void 0 ? void 0 : _b.signals) || [])
453
+ .filter((signal) => {
454
+ var _a;
455
+ const name = signalPackageName(signal);
456
+ if (name)
457
+ return !((_a = previousSignals.byPackage.get(name)) === null || _a === void 0 ? void 0 : _a.has(signal.type));
458
+ return !previousSignals.global.has(signal.type);
459
+ })
460
+ .map((signal) => `${signalPackageLabel(signal)} introduced supply-chain signal: ${signal.type}`)
461
+ .sort();
462
+ if (details.length > 0) {
463
+ violations.push({
464
+ rule: 'new-supply-chain-signal',
465
+ count: details.length,
466
+ message: `${details.length} new supply-chain ${pluralize(details.length, 'signal', 'signals')}`,
467
+ details
468
+ });
469
+ }
470
+ }
471
+ if (rules.has('new-install-script')) {
472
+ const details = currentDeps
473
+ .filter((dep) => {
474
+ if (sortedHooks(dep).length === 0)
475
+ return false;
476
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => sortedHooks(previousDep).length > 0);
477
+ })
478
+ .map((dep) => `${formatPackage(dep)} introduced install hooks: ${sortedHooks(dep).join(', ')}`)
479
+ .sort();
480
+ if (details.length > 0) {
481
+ violations.push({
482
+ rule: 'new-install-script',
483
+ count: details.length,
484
+ message: `${details.length} new ${pluralize(details.length, 'install script surface', 'install script surfaces')}`,
485
+ details
486
+ });
487
+ }
488
+ }
489
+ if (rules.has('new-native-binding')) {
490
+ const details = currentDeps
491
+ .filter((dep) => {
492
+ var _a;
493
+ if (!((_a = dep.execution) === null || _a === void 0 ? void 0 : _a.native))
494
+ return false;
495
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => { var _a; return Boolean((_a = previousDep.execution) === null || _a === void 0 ? void 0 : _a.native); });
496
+ })
497
+ .map((dep) => `${formatPackage(dep)} introduced native build/binary surface`)
498
+ .sort();
499
+ if (details.length > 0) {
500
+ violations.push({
501
+ rule: 'new-native-binding',
502
+ count: details.length,
503
+ message: `${details.length} new native ${pluralize(details.length, 'binding surface', 'binding surfaces')}`,
504
+ details
505
+ });
506
+ }
507
+ }
508
+ if (rules.has('new-bin')) {
509
+ const details = currentDeps
510
+ .filter((dep) => {
511
+ var _a;
512
+ if (!((_a = dep.package) === null || _a === void 0 ? void 0 : _a.hasBin))
513
+ return false;
514
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => { var _a; return Boolean((_a = previousDep.package) === null || _a === void 0 ? void 0 : _a.hasBin); });
515
+ })
516
+ .map((dep) => `${formatPackage(dep)} introduced a package bin`)
517
+ .sort();
518
+ if (details.length > 0) {
519
+ violations.push({
520
+ rule: 'new-bin',
521
+ count: details.length,
522
+ message: `${details.length} new package ${pluralize(details.length, 'bin', 'bins')}`,
523
+ details
524
+ });
525
+ }
526
+ }
527
+ if (rules.has('new-direct-dependency')) {
528
+ const details = currentDeps
529
+ .filter((dep) => {
530
+ var _a;
531
+ if (!((_a = dep.usage) === null || _a === void 0 ? void 0 : _a.direct))
532
+ return false;
533
+ return !(previousByName.get(dep.package.name) || []).some((previousDep) => { var _a; return Boolean((_a = previousDep.usage) === null || _a === void 0 ? void 0 : _a.direct); });
534
+ })
535
+ .map((dep) => `${formatPackage(dep)} is now a direct dependency`)
536
+ .sort();
537
+ if (details.length > 0) {
538
+ violations.push({
539
+ rule: 'new-direct-dependency',
540
+ count: details.length,
541
+ message: `${details.length} new direct ${pluralize(details.length, 'dependency', 'dependencies')}`,
542
+ details
543
+ });
544
+ }
545
+ }
546
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-child-process', 'child-process');
547
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-network-access', 'network-access');
548
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-env-access', 'reads-env');
549
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-home-access', 'reads-home');
550
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-ssh-usage', 'uses-ssh');
551
+ pushNewExecutionSignalViolation(violations, previousByName, currentDeps, rules, 'new-obfuscation-signal', 'obfuscated');
552
+ pushNewPackagingSignalViolation(violations, previousByName, currentDeps, rules, 'new-bundled-dependencies', 'bundled-dependencies');
553
+ pushNewPackagingSignalViolation(violations, previousByName, currentDeps, rules, 'new-shrinkwrap', 'embedded-shrinkwrap');
554
+ pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, 'new-recent-package', 'recent-package');
555
+ pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, 'new-recent-version', 'recent-version');
556
+ pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, 'new-low-release-history', 'low-release-history');
557
+ pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, 'new-reactivated-package', 'reactivated-package');
558
+ pushNewRegistrySignalViolation(violations, previousByName, currentDeps, rules, 'new-old-major-patch', 'old-major-new-patch');
559
+ return violations;
560
+ }