dependency-radar 0.8.1 → 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.
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.aggregateData = aggregateData;
7
+ exports.detectLocalExecutionSignals = detectLocalExecutionSignals;
8
+ exports.collectPackageExecutionSignals = collectPackageExecutionSignals;
7
9
  const utils_1 = require("./utils");
8
10
  const license_1 = require("./license");
9
11
  const findings_1 = require("./findings");
@@ -287,6 +289,17 @@ function isWorkspacePackageNode(node, input) {
287
289
  }
288
290
  return false;
289
291
  }
292
+ /**
293
+ * Builds a consolidated AggregatedData object for a dependency and audit scan.
294
+ *
295
+ * Combines package metadata, dependency graph structure, vulnerability summaries,
296
+ * import/usage data, outdated status, supply-chain signals, and per-dependency
297
+ * heuristics (license, execution/installation scripts, packaging, types support,
298
+ * links, peer requirements, and upgrade blockers) into a single summary object.
299
+ *
300
+ * @param input - All inputs and options required to perform aggregation (tool outputs, workspace/configuration, resolution paths, platform/runtime metadata, and optional policy overrides).
301
+ * @returns The assembled AggregatedData containing project metadata, environment info, per-dependency records, findings, and summary counts.
302
+ */
290
303
  async function aggregateData(input) {
291
304
  var _a, _b, _c, _d, _e, _f, _g, _h, _j;
292
305
  const pkg = input.pkgOverride || (await (0, utils_1.readPackageJson)(input.projectPath));
@@ -351,6 +364,7 @@ async function aggregateData(input) {
351
364
  const parentIds = Array.from(node.parents).sort();
352
365
  const origins = buildOrigins(rootCauses, parentIds, (_f = input.workspaceUsage) === null || _f === void 0 ? void 0 : _f.get(node.name), input.workspaceEnabled, MAX_TOP_ROOT_PACKAGES, MAX_TOP_PARENT_PACKAGES);
353
366
  const execution = packageInsights.execution;
367
+ const packaging = packageInsights.packaging;
354
368
  const id = node.key;
355
369
  const upgrade = buildUpgradeBlock(packageInsights);
356
370
  const outdated = resolveOutdated(node, direct, outdatedById, outdatedUnknownNames);
@@ -414,7 +428,8 @@ async function aggregateData(input) {
414
428
  fanOut: node.children.size,
415
429
  ...(subDeps ? { subDeps } : {})
416
430
  },
417
- ...(execution ? { execution } : {})
431
+ ...(execution ? { execution } : {}),
432
+ ...(packaging ? { packaging } : {})
418
433
  };
419
434
  }
420
435
  const minRequiredMajor = deriveMinRequiredMajor(nodeEngineRanges);
@@ -1243,6 +1258,16 @@ function buildUpgradeBlock(insights) {
1243
1258
  ...(blocksNodeMajor ? { blocksNodeMajor: true } : {})
1244
1259
  };
1245
1260
  }
1261
+ /**
1262
+ * Collects package metadata and bounded filesystem heuristics to produce insights used for dependency analysis.
1263
+ *
1264
+ * Gathers package.json data (description, engines, scripts, bin, dependencies, links), computes file-based stats when the package directory is available, detects TypeScript typing availability (bundled or DefinitelyTyped), counts required peer dependencies, and derives execution and packaging signals for use in aggregation and risk heuristics.
1265
+ *
1266
+ * @param resolvePaths - Array of filesystem roots to use when resolving package metadata.
1267
+ * @param metaCache - Cache mapping package id (`name` or `name@version`) to previously loaded PackageMeta to avoid repeated resolution and parsing.
1268
+ * @param statCache - Cache mapping package directory paths to previously computed PackageStats to avoid repeated filesystem scans.
1269
+ * @returns A PackageInsights object containing deprecation, engine constraint, required peer count, optional fileCount, declared dependency maps, links, execution signals, optional packaging signals, and TypeScript types information.
1270
+ */
1246
1271
  async function gatherPackageInsights(name, version, resolvePaths, metaCache, statCache) {
1247
1272
  var _a;
1248
1273
  const meta = await loadPackageMeta(name, resolvePaths, metaCache, version);
@@ -1277,7 +1302,8 @@ async function gatherPackageInsights(name, version, resolvePaths, metaCache, sta
1277
1302
  const hasDefinitelyTyped = await hasDefinitelyTypedPackage(name, resolvePaths, metaCache);
1278
1303
  const tsTypes = determineTypes(pkg, (stats === null || stats === void 0 ? void 0 : stats.hasDts) || false, hasDefinitelyTyped);
1279
1304
  const links = extractPackageLinks(pkg);
1280
- const execution = await deriveExecutionInfo(scripts, dir, stats);
1305
+ const execution = await deriveExecutionInfo(pkg, scripts, dir, stats);
1306
+ const packaging = derivePackagingInfo(pkg, stats);
1281
1307
  return {
1282
1308
  deprecated,
1283
1309
  nodeEngine,
@@ -1288,6 +1314,7 @@ async function gatherPackageInsights(name, version, resolvePaths, metaCache, sta
1288
1314
  declaredDependencies,
1289
1315
  links,
1290
1316
  execution,
1317
+ ...(packaging ? { packaging } : {}),
1291
1318
  tsTypes
1292
1319
  };
1293
1320
  }
@@ -1321,6 +1348,12 @@ function countRequiredPeerDependencies(peerDependencies, peerDependenciesMeta) {
1321
1348
  }
1322
1349
  return count;
1323
1350
  }
1351
+ /**
1352
+ * Detects whether a package.json `bin` field indicates at least one executable target.
1353
+ *
1354
+ * @param binField - The `bin` value from a package.json (string or object)
1355
+ * @returns `true` if `binField` contains at least one non-empty string entry, `false` otherwise.
1356
+ */
1324
1357
  function hasPackageBin(binField) {
1325
1358
  if (typeof binField === 'string')
1326
1359
  return binField.trim().length > 0;
@@ -1328,6 +1361,58 @@ function hasPackageBin(binField) {
1328
1361
  return false;
1329
1362
  return Object.values(binField).some((value) => typeof value === 'string' && value.trim().length > 0);
1330
1363
  }
1364
+ /**
1365
+ * Normalize a package's `bundledDependencies` / `bundleDependencies` field into a canonical list.
1366
+ *
1367
+ * @param pkg - The package.json-like object to read bundling metadata from
1368
+ * @returns A sorted list of bundled package names. Returns `['*']` when bundling is declared as `true`, or an empty array when no bundled dependencies are specified.
1369
+ */
1370
+ function normalizeBundledDependencies(pkg) {
1371
+ if ((pkg === null || pkg === void 0 ? void 0 : pkg.bundledDependencies) === true || (pkg === null || pkg === void 0 ? void 0 : pkg.bundleDependencies) === true) {
1372
+ return ['*'];
1373
+ }
1374
+ const raw = Array.isArray(pkg === null || pkg === void 0 ? void 0 : pkg.bundledDependencies)
1375
+ ? pkg.bundledDependencies
1376
+ : Array.isArray(pkg === null || pkg === void 0 ? void 0 : pkg.bundleDependencies)
1377
+ ? pkg.bundleDependencies
1378
+ : [];
1379
+ const entries = raw
1380
+ .map((entry) => typeof entry === 'string' ? entry.trim() : '')
1381
+ .filter((entry) => entry.length > 0);
1382
+ return Array.from(new Set(entries)).sort();
1383
+ }
1384
+ /**
1385
+ * Derives packaging-related signals from a package manifest and optional filesystem stats.
1386
+ *
1387
+ * @param pkg - The package.json object for the package being analyzed.
1388
+ * @param stats - Optional file-statistics for the package directory (e.g., presence of shrinkwrap).
1389
+ * @returns Packaging information containing `signals` (one or more of `bundled-dependencies` and `embedded-shrinkwrap`) and, when present, a `bundledDependencies` list; returns `undefined` when no packaging signals are detected.
1390
+ */
1391
+ function derivePackagingInfo(pkg, stats) {
1392
+ const signals = new Set();
1393
+ const bundledDependencies = normalizeBundledDependencies(pkg);
1394
+ if (bundledDependencies.length > 0)
1395
+ signals.add('bundled-dependencies');
1396
+ if (stats === null || stats === void 0 ? void 0 : stats.hasShrinkwrap)
1397
+ signals.add('embedded-shrinkwrap');
1398
+ const signalList = ['bundled-dependencies', 'embedded-shrinkwrap']
1399
+ .filter((signal) => signals.has(signal));
1400
+ if (signalList.length === 0)
1401
+ return undefined;
1402
+ return {
1403
+ signals: signalList,
1404
+ ...(bundledDependencies.length > 0 ? { bundledDependencies } : {})
1405
+ };
1406
+ }
1407
+ /**
1408
+ * Load and cache a package's package.json and its directory metadata.
1409
+ *
1410
+ * @param name - Package name to resolve
1411
+ * @param resolvePaths - Ordered list of base paths to use when resolving the package.json
1412
+ * @param cache - Map used to store and reuse previously loaded PackageMeta entries; keyed by `name` or `name@version`
1413
+ * @param version - Optional version hint used when resolving a specific package variant
1414
+ * @returns The `PackageMeta` containing the parsed `package.json` (`pkg`) and its directory (`dir`), or `undefined` if the package.json could not be resolved or read
1415
+ */
1331
1416
  async function loadPackageMeta(name, resolvePaths, cache, version) {
1332
1417
  const cacheKey = version ? `${name}@${version}` : name;
1333
1418
  if (cache.has(cacheKey))
@@ -1366,12 +1451,33 @@ async function hasDefinitelyTypedPackage(name, resolvePaths, cache) {
1366
1451
  const meta = await loadPackageMeta(typesName, resolvePaths, cache);
1367
1452
  return Boolean(meta);
1368
1453
  }
1454
+ /**
1455
+ * Collects filesystem-derived statistics for a package directory.
1456
+ *
1457
+ * Scans the package directory (best-effort) to detect whether the package
1458
+ * contains TypeScript declaration files, native binary artifacts, a
1459
+ * binding.gyp file, or an npm shrinkwrap file, and counts files. Nested
1460
+ * dependency stores (node_modules) and .git directories are ignored; I/O
1461
+ * errors are swallowed and the function returns whatever information could
1462
+ * be gathered.
1463
+ *
1464
+ * @param dir - Filesystem path to the package directory to inspect.
1465
+ * @param cache - Memoization map keyed by directory path; cached results are
1466
+ * returned when available and new results are stored here.
1467
+ * @returns An object with:
1468
+ * - `hasDts`: `true` when any `.d.ts` files were found.
1469
+ * - `hasNativeBinary`: `true` when any `.node` binaries were found.
1470
+ * - `hasBindingGyp`: `true` when a `binding.gyp` file was found.
1471
+ * - `hasShrinkwrap`: `true` when an `npm-shrinkwrap.json` file was found.
1472
+ * - `fileCount`: total number of regular files encountered under the directory.
1473
+ */
1369
1474
  async function calculatePackageStats(dir, cache) {
1370
1475
  if (cache.has(dir))
1371
1476
  return cache.get(dir);
1372
1477
  let hasDts = false;
1373
1478
  let hasNativeBinary = false;
1374
1479
  let hasBindingGyp = false;
1480
+ let hasShrinkwrap = false;
1375
1481
  let fileCount = 0;
1376
1482
  async function walk(current) {
1377
1483
  const entries = await promises_1.default.readdir(current, { withFileTypes: true });
@@ -1393,6 +1499,8 @@ async function calculatePackageStats(dir, cache) {
1393
1499
  hasNativeBinary = true;
1394
1500
  if (entry.name === 'binding.gyp')
1395
1501
  hasBindingGyp = true;
1502
+ if (entry.name === 'npm-shrinkwrap.json')
1503
+ hasShrinkwrap = true;
1396
1504
  }
1397
1505
  }
1398
1506
  }
@@ -1402,7 +1510,7 @@ async function calculatePackageStats(dir, cache) {
1402
1510
  catch (err) {
1403
1511
  // best-effort; ignore inaccessible paths
1404
1512
  }
1405
- const result = { hasDts, hasNativeBinary, hasBindingGyp, fileCount };
1513
+ const result = { hasDts, hasNativeBinary, hasBindingGyp, hasShrinkwrap, fileCount };
1406
1514
  cache.set(dir, result);
1407
1515
  return result;
1408
1516
  }
@@ -1493,7 +1601,16 @@ const EXECUTION_SIGNAL_ORDER = [
1493
1601
  'uses-ssh'
1494
1602
  ];
1495
1603
  const INSTALL_SCRIPT_MAX_BYTES = 200000;
1604
+ const LOCAL_SIGNAL_MAX_FILES_PER_PACKAGE = 24;
1605
+ const LOCAL_SIGNAL_MAX_BYTES_PER_FILE = 120000;
1606
+ const LOCAL_SIGNAL_MAX_PACKAGE_FILES = 2000;
1496
1607
  const COMPLEXITY_THRESHOLD = 12;
1608
+ /**
1609
+ * Collects non-empty lifecycle hook commands from a package `scripts` object.
1610
+ *
1611
+ * @param scripts - The `scripts` section from a package.json (map of script names to values).
1612
+ * @returns An object mapping each lifecycle hook found in `scripts` to its trimmed command; only hooks defined in `LIFECYCLE_HOOKS` and with non-empty string values are included.
1613
+ */
1497
1614
  function collectLifecycleScripts(scripts) {
1498
1615
  const lifecycle = {};
1499
1616
  for (const hook of LIFECYCLE_HOOKS) {
@@ -1566,6 +1683,12 @@ function extractNodeScriptPath(command) {
1566
1683
  }
1567
1684
  return undefined;
1568
1685
  }
1686
+ /**
1687
+ * Finds the first Node script file path referenced by lifecycle hook commands, checking hooks in predefined order.
1688
+ *
1689
+ * @param lifecycleScripts - Map of lifecycle hook names to their command strings; only hooks with non-empty commands are inspected.
1690
+ * @returns The referenced script path (as found in a `node <script>` invocation) when present, `undefined` otherwise.
1691
+ */
1569
1692
  function findReferencedInstallScript(lifecycleScripts) {
1570
1693
  for (const hook of LIFECYCLE_HOOKS) {
1571
1694
  const command = lifecycleScripts[hook];
@@ -1577,6 +1700,80 @@ function findReferencedInstallScript(lifecycleScripts) {
1577
1700
  }
1578
1701
  return undefined;
1579
1702
  }
1703
+ /**
1704
+ * Normalize an input into a package-relative path by trimming whitespace and removing a leading `./` or `.\`.
1705
+ *
1706
+ * @param value - The input value to normalize (expected to be a path string)
1707
+ * @returns The cleaned package-relative path, or `undefined` if the input is not a string, is empty after trimming, or appears to be an absolute/URL (contains `://`)
1708
+ */
1709
+ function normalizePackageRelativePath(value) {
1710
+ if (typeof value !== 'string')
1711
+ return undefined;
1712
+ const cleaned = value.trim().replace(/^[.][/\\]/, '');
1713
+ if (!cleaned || cleaned.includes('://'))
1714
+ return undefined;
1715
+ return cleaned;
1716
+ }
1717
+ /**
1718
+ * Collects normalized executable entry targets from a package `bin` field.
1719
+ *
1720
+ * @param binField - The package `bin` field; may be a string or an object mapping executable names to paths.
1721
+ * @returns An array of normalized package-relative executable target paths, deduplicated and sorted.
1722
+ */
1723
+ function packageBinTargets(binField) {
1724
+ const targets = new Set();
1725
+ const add = (value) => {
1726
+ const normalized = normalizePackageRelativePath(value);
1727
+ if (normalized)
1728
+ targets.add(normalized);
1729
+ };
1730
+ if (typeof binField === 'string')
1731
+ add(binField);
1732
+ else if (binField && typeof binField === 'object') {
1733
+ for (const value of Object.values(binField))
1734
+ add(value);
1735
+ }
1736
+ return Array.from(targets).sort();
1737
+ }
1738
+ /**
1739
+ * Collects candidate entry file paths from a package manifest.
1740
+ *
1741
+ * Supports `main`, `module`, and `exports` fields, flattening arrays and objects and normalizing package-relative paths.
1742
+ *
1743
+ * @param pkg - The package.json object to read entry targets from
1744
+ * @returns An array of normalized package-relative entry paths, sorted; contains `index.js` when no entry fields are present
1745
+ */
1746
+ function packageEntryTargets(pkg) {
1747
+ const targets = new Set();
1748
+ const add = (value) => {
1749
+ if (typeof value === 'string') {
1750
+ const normalized = normalizePackageRelativePath(value);
1751
+ if (normalized)
1752
+ targets.add(normalized);
1753
+ return;
1754
+ }
1755
+ if (Array.isArray(value)) {
1756
+ value.forEach(add);
1757
+ return;
1758
+ }
1759
+ if (value && typeof value === 'object') {
1760
+ Object.values(value).forEach(add);
1761
+ }
1762
+ };
1763
+ add(pkg === null || pkg === void 0 ? void 0 : pkg.main);
1764
+ add(pkg === null || pkg === void 0 ? void 0 : pkg.module);
1765
+ add(pkg === null || pkg === void 0 ? void 0 : pkg.exports);
1766
+ if (targets.size === 0)
1767
+ targets.add('index.js');
1768
+ return Array.from(targets).sort();
1769
+ }
1770
+ /**
1771
+ * Reads an install script file from a package directory if it is a regular file within size limits.
1772
+ *
1773
+ * @param scriptPath - The path to the script file as referenced in package scripts (resolved relative to `packageDir`)
1774
+ * @param packageDir - The package root directory used to resolve and bound `scriptPath`
1775
+ * @returns The file contents as UTF-8 text when the file exists inside `packageDir`, is a regular file, and its size is at most `INSTALL_SCRIPT_MAX_BYTES`; `undefined` otherwise.
1776
+ */
1580
1777
  async function readInstallScriptFile(scriptPath, packageDir) {
1581
1778
  const resolvedDir = path_1.default.resolve(packageDir);
1582
1779
  const resolvedPath = path_1.default.resolve(resolvedDir, scriptPath);
@@ -1594,14 +1791,182 @@ async function readInstallScriptFile(scriptPath, packageDir) {
1594
1791
  return undefined;
1595
1792
  }
1596
1793
  }
1794
+ /**
1795
+ * Checks whether a file path refers to an inspectable source file used for static analysis.
1796
+ *
1797
+ * @param filePath - The file path to test (relative or absolute)
1798
+ * @returns `true` if the path ends with a JS/TS-related extension (`.js`, `.cjs`, `.mjs`, `.jsx`, `.ts`, `.tsx`) or has no extension, `false` otherwise.
1799
+ */
1800
+ function isInspectableSourcePath(filePath) {
1801
+ return /\.(?:js|cjs|mjs|jsx|ts|tsx)$/i.test(filePath) || path_1.default.extname(filePath) === '';
1802
+ }
1803
+ /**
1804
+ * Detects whether a JavaScript-like file is likely minified or otherwise obfuscated.
1805
+ *
1806
+ * Uses filename and simple content heuristics to identify minified files.
1807
+ *
1808
+ * @param fileName - The file name or path (used to detect `.min.js/.min.cjs/.min.mjs` suffixes)
1809
+ * @param text - The file contents to inspect
1810
+ * @returns `true` if the file appears minified or obfuscated, `false` otherwise.
1811
+ */
1812
+ function looksMinified(fileName, text) {
1813
+ if (/\.min\.(?:js|cjs|mjs)$/i.test(fileName))
1814
+ return true;
1815
+ const lines = text.split(/\r?\n/);
1816
+ if (lines.length <= 3 && text.length > 20000)
1817
+ return true;
1818
+ return lines.some((line) => line.length > 10000);
1819
+ }
1820
+ /**
1821
+ * Determine whether a string appears to be readable text (not binary or overwhelmingly control-character data).
1822
+ *
1823
+ * Empty strings are considered text; presence of a NUL character marks the input as non-text. The function treats
1824
+ * the input as text when the proportion of suspicious control or replacement characters is less than 1%.
1825
+ *
1826
+ * @param text - The string to evaluate
1827
+ * @returns `true` if the input appears to be readable text, `false` otherwise.
1828
+ */
1829
+ function looksTextLike(text) {
1830
+ if (text.includes('\0'))
1831
+ return false;
1832
+ if (text.length === 0)
1833
+ return true;
1834
+ const suspicious = (text.match(/[\uFFFD\x00-\x08\x0E-\x1F]/g) || []).length;
1835
+ return suspicious / text.length < 0.01;
1836
+ }
1837
+ /**
1838
+ * Detects whether a text blob appears to be JavaScript or Node.js source.
1839
+ *
1840
+ * @param text - The text to inspect for JavaScript/Node indicators
1841
+ * @returns `true` if the text contains common JavaScript or Node.js source markers (shebang with `node`, `require(`, `import`, `module.exports`, or `process.`), `false` otherwise
1842
+ */
1843
+ function looksJavaScriptLike(text) {
1844
+ return (/^#!.*\bnode\b/.test(text) ||
1845
+ /\brequire\s*\(/.test(text) ||
1846
+ /\bimport\s+/.test(text) ||
1847
+ /\bmodule\.exports\b/.test(text) ||
1848
+ /\bprocess\./.test(text));
1849
+ }
1850
+ /**
1851
+ * Read and return the text of a package-relative source file when it is safe and useful to inspect.
1852
+ *
1853
+ * Attempts to resolve and read `filePath` inside `packageDir` and returns the file contents only if the file:
1854
+ * - resides within `packageDir`,
1855
+ * - matches the allowed inspectable source path patterns,
1856
+ * - is a regular file whose size does not exceed `maxBytes`,
1857
+ * - appears to be text (and, for extensionless files, looks like JavaScript),
1858
+ * - does not appear minified or otherwise obfuscated.
1859
+ *
1860
+ * @param filePath - Path to the candidate file relative to the package directory
1861
+ * @param packageDir - Absolute path of the package root to constrain reads
1862
+ * @param maxBytes - Maximum number of bytes to read from the file (per-file size limit)
1863
+ * @returns The file content when it is inspectable under the above constraints, `undefined` otherwise
1864
+ */
1865
+ async function readInspectablePackageFile(filePath, packageDir, maxBytes = LOCAL_SIGNAL_MAX_BYTES_PER_FILE) {
1866
+ const resolvedDir = path_1.default.resolve(packageDir);
1867
+ const resolvedPath = path_1.default.resolve(resolvedDir, filePath);
1868
+ if (!resolvedPath.startsWith(resolvedDir + path_1.default.sep))
1869
+ return undefined;
1870
+ if (!isInspectableSourcePath(resolvedPath))
1871
+ return undefined;
1872
+ try {
1873
+ const stat = await promises_1.default.stat(resolvedPath);
1874
+ if (!stat.isFile())
1875
+ return undefined;
1876
+ if (stat.size > maxBytes)
1877
+ return undefined;
1878
+ const text = await promises_1.default.readFile(resolvedPath, 'utf8');
1879
+ if (!looksTextLike(text))
1880
+ return undefined;
1881
+ if (path_1.default.extname(resolvedPath) === '' && !looksJavaScriptLike(text))
1882
+ return undefined;
1883
+ if (looksMinified(resolvedPath, text))
1884
+ return undefined;
1885
+ return text;
1886
+ }
1887
+ catch {
1888
+ return undefined;
1889
+ }
1890
+ }
1891
+ /**
1892
+ * Collects a bounded list of inspectable source file paths inside a package directory.
1893
+ *
1894
+ * Traverses the package tree (skipping symlinks and common non-source directories) and returns
1895
+ * a sorted array of package-relative paths for files considered inspectable.
1896
+ *
1897
+ * @param packageDir - Absolute path to the package directory to scan
1898
+ * @param maxFiles - Maximum number of inspectable file paths to return
1899
+ * @param maxPackageFiles - Maximum number of files to examine within the package (counts all files visited)
1900
+ * @returns A sorted array of relative file paths (relative to `packageDir`) for inspectable source files
1901
+ */
1902
+ async function collectBoundedInspectableFiles(packageDir, maxFiles, maxPackageFiles) {
1903
+ const out = [];
1904
+ let seen = 0;
1905
+ async function walk(current) {
1906
+ if (out.length >= maxFiles || seen >= maxPackageFiles)
1907
+ return;
1908
+ const entries = await promises_1.default.readdir(current, { withFileTypes: true }).catch(() => []);
1909
+ entries.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));
1910
+ for (const entry of entries) {
1911
+ if (out.length >= maxFiles || seen >= maxPackageFiles)
1912
+ return;
1913
+ const full = path_1.default.join(current, entry.name);
1914
+ if (entry.isSymbolicLink())
1915
+ continue;
1916
+ if (entry.isDirectory()) {
1917
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'test' || entry.name === 'tests' || entry.name === 'docs')
1918
+ continue;
1919
+ await walk(full);
1920
+ }
1921
+ else if (entry.isFile()) {
1922
+ seen += 1;
1923
+ if (isInspectableSourcePath(full)) {
1924
+ out.push(path_1.default.relative(packageDir, full));
1925
+ }
1926
+ }
1927
+ }
1928
+ }
1929
+ await walk(packageDir);
1930
+ return out.sort();
1931
+ }
1932
+ /**
1933
+ * Identify execution-related static signals present in a block of source or script text.
1934
+ *
1935
+ * @param text - The file or lifecycle script text to analyze for execution signals
1936
+ * @returns An ordered array of distinct execution signals detected in `text`, prioritized according to the canonical execution-signal ordering
1937
+ */
1938
+ function detectLocalExecutionSignals(text) {
1939
+ const signals = new Set();
1940
+ detectFileSignals(text, signals);
1941
+ return EXECUTION_SIGNAL_ORDER.filter((signal) => signals.has(signal));
1942
+ }
1943
+ /**
1944
+ * Checks whether the given text matches any regular expression in the provided list.
1945
+ *
1946
+ * @param text - The string to test against the patterns
1947
+ * @param patterns - Array of `RegExp` objects to test
1948
+ * @returns `true` if at least one pattern matches `text`, `false` otherwise.
1949
+ */
1597
1950
  function textHasAny(text, patterns) {
1598
1951
  return patterns.some((pattern) => pattern.test(text));
1599
1952
  }
1953
+ /**
1954
+ * Detects install-time script characteristics from a text blob and adds matching execution signals to the provided set.
1955
+ *
1956
+ * Examines the input text with heuristics for common patterns and adds any of the following `ExecutionSignal` values to `signals` when matched:
1957
+ * - `network-access` — patterns that fetch remote resources (curl, wget, http(s), fetch, axios, net.connect, dns, etc.).
1958
+ * - `reads-env` — access to environment variables or printenv-style usage.
1959
+ * - `reads-home` — references to user home paths or APIs returning the home directory.
1960
+ * - `uses-ssh` — references to SSH-related files, agents, or SSH/git configuration.
1961
+ *
1962
+ * @param text - The script or file text to statically inspect for indicative patterns.
1963
+ * @param signals - A mutable Set that will receive any detected execution signals.
1964
+ */
1600
1965
  function detectScriptSignals(text, signals) {
1601
1966
  // Signals are derived from static text inspection only: no code execution and no import walking.
1602
1967
  // They are NOT malware detection; they merely highlight review-worthy install-time behavior.
1603
1968
  // "network-access" surfaces install scripts that fetch remote resources (review for expected downloads; does NOT imply exfiltration).
1604
- if (textHasAny(text, [/\bcurl\b/i, /\bwget\b/i, /https?:\/\//i, /\bfetch\s*\(/i, /\baxios\b/i, /node-fetch/i])) {
1969
+ if (textHasAny(text, [/\bcurl\b/i, /\bwget\b/i, /https?:\/\//i, /\bfetch\s*\(/i, /\baxios\b/i, /node-fetch/i, /\bhttps?\.request\s*\(/, /\bnet\.connect\s*\(/, /\bdns\./])) {
1605
1970
  signals.add('network-access');
1606
1971
  }
1607
1972
  // "reads-env" highlights environment access (does NOT imply exfiltration).
@@ -1609,11 +1974,11 @@ function detectScriptSignals(text, signals) {
1609
1974
  signals.add('reads-env');
1610
1975
  }
1611
1976
  // "reads-home" highlights access to user home paths (does NOT imply credential theft).
1612
- if (textHasAny(text, [/\$HOME\b/, /process\.env\.HOME\b/, /os\.homedir\s*\(/, /~\//])) {
1977
+ if (textHasAny(text, [/\$HOME\b/, /process\.env\.HOME\b/, /\bUSERPROFILE\b/, /os\.homedir\s*\(/, /~\//, /\/Users\//, /\/home\//])) {
1613
1978
  signals.add('reads-home');
1614
1979
  }
1615
1980
  // "uses-ssh" flags access to SSH-related paths (does NOT imply key exfiltration).
1616
- if (textHasAny(text, [/\.ssh\b/i, /id_rsa\b/i, /known_hosts\b/i, /\.npmrc\b/i])) {
1981
+ if (textHasAny(text, [/\.ssh\b/i, /id_rsa\b/i, /known_hosts\b/i, /ssh-key/i, /ssh-agent/i, /GIT_SSH_COMMAND\b/, /\.npmrc\b/i])) {
1617
1982
  signals.add('uses-ssh');
1618
1983
  }
1619
1984
  }
@@ -1634,6 +1999,14 @@ function isObfuscated(text) {
1634
1999
  }
1635
2000
  return /[A-Za-z0-9+/]{800,}={0,2}/.test(text);
1636
2001
  }
2002
+ /**
2003
+ * Detects static execution- and obfuscation-related signals from a JavaScript file's text and adds them to `signals`.
2004
+ *
2005
+ * Scans the provided source text for review cues such as script-level indicators, dynamic code execution APIs, child-process usage, explicit encoding patterns, and signs of obfuscation/minification. Matches are added to the supplied `signals` set; the function does not return a value and does not imply malicious intent by itself.
2006
+ *
2007
+ * @param text - The source text of a JavaScript file to analyze.
2008
+ * @param signals - A mutable set that will be populated with discovered `ExecutionSignal` values.
2009
+ */
1637
2010
  function detectFileSignals(text, signals) {
1638
2011
  // Signals from a single directly-referenced JS file (no execution, no imports, no deep scanning).
1639
2012
  // These are review cues only and do NOT imply malicious intent.
@@ -1643,7 +2016,7 @@ function detectFileSignals(text, signals) {
1643
2016
  signals.add('dynamic-exec');
1644
2017
  }
1645
2018
  // "child-process" flags process spawning (does NOT imply abuse).
1646
- if (textHasAny(text, [/\bchild_process\.exec\b/, /\bspawn\s*\(/, /\bexecSync\s*\(/])) {
2019
+ if (textHasAny(text, [/\bchild_process\b/, /\bexec\s*\(/, /\bspawn\s*\(/, /\bexecFile\s*\(/, /\bexecSync\s*\(/, /\bspawnSync\s*\(/])) {
1647
2020
  signals.add('child-process');
1648
2021
  }
1649
2022
  // "encoding" flags explicit encode/decode flows (does NOT imply obfuscation intent).
@@ -1655,46 +2028,116 @@ function detectFileSignals(text, signals) {
1655
2028
  signals.add('obfuscated');
1656
2029
  }
1657
2030
  }
2031
+ /**
2032
+ * Classifies execution-related risk level for a package based on lifecycle scripts, detected signals, and script complexity.
2033
+ *
2034
+ * @param hasScripts - Whether the package defines any lifecycle scripts
2035
+ * @param hasSignals - Whether static analysis detected execution-related signals (network, child-process, dynamic execution, etc.)
2036
+ * @param highComplexity - Whether the package's lifecycle scripts exceed the complexity threshold
2037
+ * @param hooks - Lifecycle hooks present (note: `install` or `postinstall` count as install-time hooks)
2038
+ * @returns `'red'` when scripts are present and either execution signals exist or scripts are highly complex on an install-time hook; `'amber'` otherwise.
2039
+ */
1658
2040
  function determineExecutionRisk(hasScripts, hasSignals, highComplexity, hooks) {
1659
2041
  const hasInstallHook = hooks.includes('install') || hooks.includes('postinstall');
1660
2042
  if (hasScripts && (hasSignals || (highComplexity && hasInstallHook)))
1661
2043
  return 'red';
1662
2044
  return 'amber';
1663
2045
  }
1664
- async function deriveExecutionInfo(scripts, packageDir, stats) {
2046
+ /**
2047
+ * Collects execution-related static signals from a package's likely entry and inspectable source files using bounded, best-effort inspection.
2048
+ *
2049
+ * This inspects candidate entry points (e.g., `bin`, `main`, `module`, `exports`) plus a bounded set of other inspectable files, applies static detectors, and returns the detected execution signals in the canonical ordering.
2050
+ *
2051
+ * @param pkg - The package.json object for the package being inspected.
2052
+ * @param packageDir - The package filesystem directory to read files from; when `undefined`, the function returns an empty array.
2053
+ * @param options.maxFiles - Maximum number of files to inspect (default: LOCAL_SIGNAL_MAX_FILES_PER_PACKAGE).
2054
+ * @param options.maxBytesPerFile - Maximum bytes to read per file (default: LOCAL_SIGNAL_MAX_BYTES_PER_FILE).
2055
+ * @param options.maxPackageFiles - Maximum number of candidate package files to enumerate before selecting up to `maxFiles` for inspection (default: LOCAL_SIGNAL_MAX_PACKAGE_FILES).
2056
+ * @returns An array of detected `ExecutionSignal` values, ordered according to the canonical `EXECUTION_SIGNAL_ORDER`. Empty if no signals are found.
2057
+ */
2058
+ async function collectPackageExecutionSignals(pkg, packageDir, options = {}) {
2059
+ var _a, _b, _c;
2060
+ if (!packageDir)
2061
+ return [];
2062
+ const maxFiles = (_a = options.maxFiles) !== null && _a !== void 0 ? _a : LOCAL_SIGNAL_MAX_FILES_PER_PACKAGE;
2063
+ const maxBytesPerFile = (_b = options.maxBytesPerFile) !== null && _b !== void 0 ? _b : LOCAL_SIGNAL_MAX_BYTES_PER_FILE;
2064
+ const maxPackageFiles = (_c = options.maxPackageFiles) !== null && _c !== void 0 ? _c : LOCAL_SIGNAL_MAX_PACKAGE_FILES;
2065
+ const candidateFiles = new Set();
2066
+ for (const file of [...packageBinTargets(pkg === null || pkg === void 0 ? void 0 : pkg.bin), ...packageEntryTargets(pkg)]) {
2067
+ candidateFiles.add(file);
2068
+ }
2069
+ for (const file of await collectBoundedInspectableFiles(packageDir, maxFiles, maxPackageFiles)) {
2070
+ candidateFiles.add(file);
2071
+ if (candidateFiles.size >= maxFiles)
2072
+ break;
2073
+ }
2074
+ const signals = new Set();
2075
+ let inspected = 0;
2076
+ for (const file of candidateFiles) {
2077
+ if (inspected >= maxFiles)
2078
+ break;
2079
+ const text = await readInspectablePackageFile(file, packageDir, maxBytesPerFile);
2080
+ if (!text)
2081
+ continue;
2082
+ inspected += 1;
2083
+ detectFileSignals(text, signals);
2084
+ }
2085
+ return EXECUTION_SIGNAL_ORDER.filter((signal) => signals.has(signal));
2086
+ }
2087
+ /**
2088
+ * Derives install-time execution signals, script metadata, and a consolidated execution risk for a package.
2089
+ *
2090
+ * Analyzes lifecycle hooks, referenced install scripts, native-build indicators, and bounded package file inspection to produce ordered execution signals, an execution complexity score when applicable, and a risk classification. Returns undefined when no meaningful execution signals, scripts, or native indicators are present.
2091
+ *
2092
+ * @param pkg - The package.json object for the dependency; used to locate entry points and candidate files for inspection.
2093
+ * @param scripts - The package's lifecycle `scripts` map (from package.json).
2094
+ * @param packageDir - Absolute path to the package directory used for reading referenced install scripts and package files; when undefined, file-based inspection is skipped.
2095
+ * @param stats - Optional PackageStats containing file-based heuristics (e.g., `hasNativeBinary`, `hasBindingGyp`) used as native indicators.
2096
+ * @returns An execution record containing:
2097
+ * - `risk`: computed execution risk,
2098
+ * - optional `native`: `true` when native-build indicators are present,
2099
+ * - optional `signals`: ordered list of detected execution signals,
2100
+ * - optional `scripts`: metadata about lifecycle hooks including `hooks`, optional `complexity`, and optional script-only `signals`.
2101
+ */
2102
+ async function deriveExecutionInfo(pkg, scripts, packageDir, stats) {
1665
2103
  const lifecycleScripts = collectLifecycleScripts(scripts);
1666
2104
  const hooks = LIFECYCLE_HOOKS.filter((hook) => Boolean(lifecycleScripts[hook]));
1667
2105
  const hasScripts = hooks.length > 0;
1668
2106
  const hasNative = Boolean((stats === null || stats === void 0 ? void 0 : stats.hasNativeBinary) || (stats === null || stats === void 0 ? void 0 : stats.hasBindingGyp) || scriptsContainNativeTooling(scripts));
1669
- if (!hasScripts && !hasNative)
1670
- return undefined;
1671
- const signals = new Set();
2107
+ const scriptSignals = new Set();
1672
2108
  const combinedScripts = hooks.map((hook) => lifecycleScripts[hook]).join('\n');
1673
2109
  if (combinedScripts) {
1674
- detectScriptSignals(combinedScripts, signals);
2110
+ detectScriptSignals(combinedScripts, scriptSignals);
1675
2111
  }
1676
2112
  if (hasScripts && packageDir) {
1677
2113
  const referencedScript = findReferencedInstallScript(lifecycleScripts);
1678
2114
  if (referencedScript) {
1679
2115
  const fileContent = await readInstallScriptFile(referencedScript, packageDir);
1680
2116
  if (fileContent) {
1681
- detectFileSignals(fileContent, signals);
2117
+ detectFileSignals(fileContent, scriptSignals);
1682
2118
  }
1683
2119
  }
1684
2120
  }
2121
+ const packageSignals = await collectPackageExecutionSignals(pkg, packageDir);
2122
+ const allSignals = new Set([...scriptSignals, ...packageSignals]);
1685
2123
  const complexityScore = hasScripts ? scoreLifecycleScripts(lifecycleScripts) : 0;
1686
2124
  const complexity = complexityScore >= COMPLEXITY_THRESHOLD ? complexityScore : undefined;
1687
- const signalList = EXECUTION_SIGNAL_ORDER.filter((signal) => signals.has(signal));
2125
+ const scriptSignalList = EXECUTION_SIGNAL_ORDER.filter((signal) => scriptSignals.has(signal));
2126
+ const signalList = EXECUTION_SIGNAL_ORDER.filter((signal) => allSignals.has(signal));
2127
+ if (!hasScripts && !hasNative && signalList.length === 0)
2128
+ return undefined;
1688
2129
  const scriptsInfo = {
1689
2130
  hooks,
1690
2131
  ...(complexity !== undefined ? { complexity } : {}),
1691
- ...(signalList.length > 0 ? { signals: signalList } : {})
2132
+ ...(scriptSignalList.length > 0 ? { signals: scriptSignalList } : {})
1692
2133
  };
1693
2134
  const risk = determineExecutionRisk(hasScripts, signalList.length > 0, complexity !== undefined, hooks);
1694
2135
  const execution = { risk };
1695
2136
  // Native is surface description only; not a behavioral signal.
1696
2137
  if (hasNative)
1697
2138
  execution.native = true;
2139
+ if (signalList.length > 0)
2140
+ execution.signals = signalList;
1698
2141
  if (hasScripts)
1699
2142
  execution.scripts = scriptsInfo;
1700
2143
  return execution;