UNPKG

snyk-mvn-plugin

Version:
445 lines • 19.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildDepGraph = buildDepGraph; exports.buildWithoutVerbose = buildWithoutVerbose; exports.buildWithVerbose = buildWithVerbose; const yocto_queue_1 = require("@common.js/yocto-queue"); const dep_graph_1 = require("@snyk/dep-graph"); const dependency_1 = require("./dependency"); const fingerprint_1 = require("../fingerprint"); const MAVEN_BUILD_SCOPE_UNKNOWN = 'unknown'; function buildDepGraph(mavenGraph, context) { const { rootId, nodes } = mavenGraph; return context.verboseEnabled ? buildWithVerbose(rootId, nodes, context) : buildWithoutVerbose(rootId, nodes, context); } function buildWithoutVerbose(rootId, nodes, context) { const { fingerprintMap, includePurl, includeTestScope } = context; const parsedRoot = parseId(rootId, true, includePurl, fingerprintMap.get(rootId)); const builder = new dep_graph_1.DepGraphBuilder({ name: 'maven' }, parsedRoot.pkgInfo, createNodeInfo(parsedRoot, context, MAVEN_BUILD_SCOPE_UNKNOWN)); const visitedMap = {}; const queue = new yocto_queue_1.default(); getItems(rootId, nodes[rootId]).forEach((item) => queue.enqueue(item)); // breadth first search while (queue.size > 0) { const item = queue.dequeue(); if (!item) continue; const { id, parentId } = item; const parsed = parseId(id, false, includePurl, fingerprintMap.get(id)); const node = nodes[id]; if (!includeTestScope && parsed.scope === 'test' && !node.reachesProdDep) { continue; } const visited = visitedMap[parsed.key]; if (visited) { const prunedId = visited.id + ':pruned'; builder.addPkgNode(visited.pkgInfo, prunedId, { labels: { pruned: 'true' }, }); builder.connectDep(parentId, prunedId); continue; // don't queue any more children } const parentNodeId = parentId === rootId ? builder.rootNodeId : parentId; builder.addPkgNode(parsed.pkgInfo, id, createNodeInfo(parsed, context)); builder.connectDep(parentNodeId, id); visitedMap[parsed.key] = parsed; getItems(id, node).forEach((item) => queue.enqueue(item)); } return builder.build(); } // Today's verbose graph is defined per path: whether an edge is drawn to a // package or to a `:pruned-cycle` placeholder depends on the ancestry of the // route taken to reach it, so the same edge can legitimately produce both. The // old walker discovered that by enumerating every path, which is O(paths) and // takes minutes to hours on a reactor where packages are reachable many ways. // // The same answer follows from two graph properties, without enumeration: // // * an edge `u -> v` closes a cycle exactly when `v` can reach `u`, i.e. the // two share a strongly connected component; // * the plain edge `u -> v` is drawn as well exactly when some route to `u` // avoids `v`, i.e. `u` is still reachable from the root once `v` is removed. // // So each edge is decided once, off one strongly-connected-components pass and // a dominator tree per cycle. function buildWithVerbose(rootId, nodes, context) { const { fingerprintMap, includePurl, includeTestScope } = context; const parsedRoot = parseId(rootId, true, includePurl, fingerprintMap.get(rootId)); const builder = new dep_graph_1.DepGraphBuilder({ name: 'maven' }, parsedRoot.pkgInfo, createNodeInfo(parsedRoot, context, MAVEN_BUILD_SCOPE_UNKNOWN)); const parsed = createDepInfoLookup(fingerprintMap, includePurl); // A test-scoped package that never reaches a production dependency is // dropped wherever it appears, so inclusion is a property of the package // rather than of the route to it. const isIncluded = (id) => includeTestScope || parsed(id).scope !== 'test' || !!nodes[id]?.reachesProdDep; const childrenOf = (id) => (nodes[id]?.dependsOn || []).filter(isIncluded); const reachableFromRoot = () => { const reached = new Set(); const stack = childrenOf(rootId); while (stack.length > 0) { const id = stack.pop(); if (reached.has(id)) continue; reached.add(id); for (const child of childrenOf(id)) { if (!reached.has(child)) stack.push(child); } } return reached; }; const reachable = reachableFromRoot(); const componentOf = findStronglyConnectedComponents(reachable, childrenOf); const dominates = buildCycleDominanceTest(childrenOf(rootId), reachable, childrenOf, componentOf); // `to` has to be able to reach `from` for the edge to sit on a cycle, and // `to` has to be reachable without `from` for it to be able to come first on // any route - otherwise `to` can only ever be seen after `from` and the edge // is an ordinary one. Both conditions are necessary but not jointly // sufficient: deciding it exactly asks whether a route to `from` passes // through `to`, which needs the two halves to be vertex-disjoint and is the // NP-hard two-disjoint-paths problem. So this is a deliberate // over-approximation - it can mark an extra edge as cyclic, and never drops // a package or a real dependency edge. const closesCycle = (from, to) => from === to || (componentOf.get(from) === componentOf.get(to) && !dominates(from, to)); // Whether some route from the root reaches `from` without passing through // `to`: if one does, `from -> to` can be walked with `to` not yet an // ancestor, so the plain edge is drawn alongside the cycle placeholder. A // self-loop never can, and its package sits in no dominator tree to say so. const canReachSourceWithoutTarget = (from, to) => from !== to && !dominates(to, from); // Every reachable package is added once: the first route to reach it cannot // already contain it, so it always gets a package node of its own. for (const id of reachable) { const depInfo = parsed(id); builder.addPkgNode(depInfo.pkgInfo, id, createNodeInfo(depInfo, context)); } const prunedAdded = new Set(); const prunedCycleNodeFor = (id) => { const prunedId = id + ':pruned-cycle'; if (!prunedAdded.has(prunedId)) { builder.addPkgNode(parsed(id).pkgInfo, prunedId, { labels: { pruned: 'cyclic' }, }); prunedAdded.add(prunedId); } return prunedId; }; // The root is never its own ancestor, so its own edges are always plain. for (const child of childrenOf(rootId)) { builder.connectDep(builder.rootNodeId, child); } for (const from of reachable) { // matches the old walk, which mapped a parent equal to the root id onto the // builder's own root node const fromNodeId = from === rootId ? builder.rootNodeId : from; for (const to of childrenOf(from)) { if (closesCycle(from, to)) { // the old walk connected placeholders from the raw parent and only // mapped the root for plain edges, so this stays asymmetric on purpose builder.connectDep(from, prunedCycleNodeFor(to)); if (!canReachSourceWithoutTarget(from, to)) continue; } builder.connectDep(fromNodeId, to); } } return builder.build(); } // Dominance is only ever asked about two packages in the same strongly // connected component, and a route from the root that enters a component // cannot leave it and come back. So whether one member dominates another // depends only on the component itself and on where routes enter it, and each // cyclic component gets a dominator tree of its own. Packages outside any // cycle - the bulk of a real dependency graph - cost nothing here. // // Keeping the trees small matters because Cooper, Harvey and Kennedy's // algorithm is quadratic in the worst case: a single tree over a long chain // whose every link also depends on one shared library climbs ever-longer // dominator chains, and took seconds at a few thousand packages. function buildCycleDominanceTest(rootChildren, packages, childrenOf, componentOf) { const componentSizes = new Map(); for (const component of componentOf.values()) { componentSizes.set(component, (componentSizes.get(component) || 0) + 1); } const isCyclic = (component) => component !== undefined && componentSizes.get(component) > 1; const entriesByComponent = new Map(); const addEntry = (id) => { const component = componentOf.get(id); if (!isCyclic(component)) return; const entries = entriesByComponent.get(component); if (entries) entries.push(id); else entriesByComponent.set(component, [id]); }; // The root's own edges are where every route starts, so its children are // entries even when something points back at the root and puts it in a // component of its own; any other member is an entry when an edge reaches // it from outside its component. rootChildren.forEach(addEntry); for (const from of packages) { for (const to of childrenOf(from)) { if (componentOf.get(from) !== componentOf.get(to)) addEntry(to); } } const dominanceByComponent = new Map(); for (const [component, entries] of entriesByComponent) { const membersOnly = (id) => childrenOf(id).filter((child) => componentOf.get(child) === component); dominanceByComponent.set(component, buildDominanceTest(entries, membersOnly)); } return (dominator, id) => { const test = dominanceByComponent.get(componentOf.get(id)); return test ? test(dominator, id) : false; }; } // A node id the graph cannot contain, so the dominator tree can have an entry // of its own that leads to every place routes come in from outside. const DOMINANCE_ENTRY = '\u0000dominance-entry'; // `to` is reachable from the entries without `from` exactly when `from` does // not dominate `to`, so one dominator tree answers every such question in // constant time. function buildDominanceTest(entries, successorsOf) { const graph = prepareDominanceGraph(DOMINANCE_ENTRY, (id) => id === DOMINANCE_ENTRY ? entries : successorsOf(id)); const immediateDominator = computeImmediateDominators(DOMINANCE_ENTRY, graph); return createDominanceLookup(DOMINANCE_ENTRY, immediateDominator); } function prepareDominanceGraph(entry, successorsOf) { // A node is recorded in postorder by its leaving frame, which is pushed // beneath its children so that it pops once they have all been walked. const postorder = []; const seen = new Set(); const stack = [{ id: entry }]; while (stack.length > 0) { const { id, leaving } = stack.pop(); if (leaving) { postorder.push(id); continue; } if (seen.has(id)) continue; seen.add(id); stack.push({ id, leaving: true }); const successors = successorsOf(id); for (let i = successors.length - 1; i >= 0; i--) { if (!seen.has(successors[i])) stack.push({ id: successors[i] }); } } const order = postorder.reverse(); const rank = new Map(); order.forEach((id, position) => rank.set(id, position)); const predecessors = new Map(); for (const id of order) { for (const successor of successorsOf(id)) { const known = predecessors.get(successor); if (known) known.push(id); else predecessors.set(successor, [id]); } } return { order, rank, predecessors }; } // Cooper, Harvey and Kennedy's iterative formulation: refine each node's // immediate dominator from its predecessors' until nothing changes. function computeImmediateDominators(entry, { order, rank, predecessors }) { const immediateDominator = new Map([[entry, entry]]); // Walks both nodes up the dominator tree built so far until they meet. const nearestCommonDominator = (left, right) => { let a = left; let b = right; while (a !== b) { while (rank.get(a) > rank.get(b)) a = immediateDominator.get(a); while (rank.get(b) > rank.get(a)) b = immediateDominator.get(b); } return a; }; let settled = false; while (!settled) { settled = true; for (const id of order) { if (id === entry) continue; let candidate; for (const predecessor of predecessors.get(id) || []) { if (!immediateDominator.has(predecessor)) continue; candidate = candidate === undefined ? predecessor : nearestCommonDominator(predecessor, candidate); } if (candidate !== undefined && immediateDominator.get(id) !== candidate) { immediateDominator.set(id, candidate); settled = false; } } } return immediateDominator; } // Entry and exit stamps over the dominator tree turn dominance into a range // check: one node dominates another when its interval encloses it. function createDominanceLookup(entry, immediateDominator) { const treeChildren = new Map(); for (const [id, parent] of immediateDominator) { if (id === entry) continue; const known = treeChildren.get(parent); if (known) known.push(id); else treeChildren.set(parent, [id]); } const entered = new Map(); const exited = new Map(); let clock = 0; const stack = [{ id: entry }]; while (stack.length > 0) { const { id, leaving } = stack.pop(); if (leaving) { exited.set(id, clock++); continue; } entered.set(id, clock++); stack.push({ id, leaving: true }); for (const child of treeChildren.get(id) || []) stack.push({ id: child }); } return (dominator, id) => { const from = entered.get(dominator); const to = entered.get(id); if (from === undefined || to === undefined) return false; return (from <= to && exited.get(id) <= exited.get(dominator)); }; } // Tarjan's algorithm, driven by an explicit stack: a recursive implementation // overflows the call stack on the deep dependency chains this is here to cope // with in the first place. function findStronglyConnectedComponents(nodeIds, childrenOf) { const index = new Map(); const lowLink = new Map(); const onStack = new Set(); const pending = []; const componentOf = new Map(); let nextIndex = 0; let nextComponent = 0; const open = (id) => { index.set(id, nextIndex); lowLink.set(id, nextIndex); nextIndex++; pending.push(id); onStack.add(id); }; for (const start of nodeIds) { if (index.has(start)) continue; open(start); const work = [{ id: start, children: childrenOf(start), next: 0 }]; while (work.length > 0) { const frame = work[work.length - 1]; if (frame.next < frame.children.length) { const child = frame.children[frame.next++]; if (!index.has(child)) { open(child); work.push({ id: child, children: childrenOf(child), next: 0 }); } else if (onStack.has(child)) { lowLink.set(frame.id, Math.min(lowLink.get(frame.id), index.get(child))); } continue; } work.pop(); if (work.length > 0) { const caller = work[work.length - 1]; lowLink.set(caller.id, Math.min(lowLink.get(caller.id), lowLink.get(frame.id))); } if (lowLink.get(frame.id) === index.get(frame.id)) { const component = nextComponent++; let member; do { member = pending.pop(); onStack.delete(member); componentOf.set(member, component); } while (member !== frame.id); } } } return componentOf; } function createNodeInfo(depInfo, context, defaultScope = 'compile') { const labels = {}; if (context.showMavenBuildScope) { labels['maven:build_scope'] = depInfo.scope ? depInfo.scope : defaultScope; } // Merge install-time-recorded hash labels (read from `.m2/.../*.sha1` etc. // companion files). These are consumed downstream to populate CycloneDX // `component.Hashes` / SPDX `Package.PackageChecksums`. const hashLabels = context.hashLabelsMap?.get(depInfo.id); if (hashLabels) { Object.assign(labels, hashLabels); } // Merge distribution-source labels (read from `.m2/.../repository/*/_remote.repositories` // and resolved to full artifact URLs via Maven's dependency:list-repositories). // These are consumed downstream to populate CycloneDX `component.ExternalReferences` // with type="distribution". const repoLabels = context.remoteRepositoriesMap?.get(depInfo.id); if (repoLabels) { Object.assign(labels, repoLabels); } if (Object.keys(labels).length === 0) { return; } return { labels }; } function getItems(parentId, node) { const items = []; for (const id of node?.dependsOn || []) { items.push({ id, parentId }); } return items; } function parseId(id, verboseEnabled = false, includePurl = false, fingerprintData) { const dep = (0, dependency_1.parseDependency)(id); const maybeClassifier = dep.classifier ? `:${dep.classifier}` : ''; const name = `${dep.groupId}:${dep.artifactId}`; // Only do expensive operations if PURL is needed let purl; if (includePurl) { purl = (0, fingerprint_1.createMavenPurlWithChecksum)(dep.groupId, dep.artifactId, dep.version, fingerprintData, dep.classifier, dep.type); } return { id, key: verboseEnabled ? `${name}:${dep.type}${maybeClassifier}:${dep.version}:${dep.scope}` : `${name}:${dep.type}${maybeClassifier}`, pkgInfo: { name, version: dep.version, ...(includePurl ? { purl } : {}), }, scope: dep.scope, }; } // Parsing an id, and building its purl, is repeated for every edge that // touches it, so each id is parsed once. function createDepInfoLookup(fingerprintMap, includePurl) { const cache = new Map(); return (id) => { let depInfo = cache.get(id); if (!depInfo) { depInfo = parseId(id, true, includePurl, fingerprintMap.get(id)); cache.set(id, depInfo); } return depInfo; }; } //# sourceMappingURL=dep-graph.js.map