UNPKG

21.6 kBPlain TextView Raw
1import groovy.json.JsonOutput
2import java.util.regex.Pattern
3import java.util.regex.Matcher
4import org.gradle.util.GradleVersion
5import org.gradle.api.internal.artifacts.DefaultResolvedDependency
6
7// Snyk dependency resolution script for Gradle.
8// Tested on Gradle versions from v2.14 to v6.8.1
9
10// This script does the following: for all the projects in the build file,
11// generate a merged configuration of all the available configurations,
12// and then list the dependencies as a tree.
13
14// It's the responsibility of the caller to pick the project(s) they are
15// interested in from the results.
16
17// CLI usages:
18// gradle -q -I init.gradle snykResolvedDepsJson
19// gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=specificConf -PonlySubProject=sub-project
20// gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=confNameRegex -PconfAttr=buildtype:debug,usage:java-runtime
21
22// (-q to have clean output, -P supplies args as per https://stackoverflow.com/a/48370451)
23
24// confAttr parameter (supported only in Gradle 3+) is used to perform attribute-based dependency variant matching
25// (important for Android: https://developer.android.com/studio/build/dependencies#variant_aware)
26// Its value is a comma-separated list of key:value pairs. The "key" is a case-insensitive substring
27// of the class name of the attribute (e.g. "buildtype" would match com.android.build.api.attributes.BuildTypeAttr),
28// the value should be a case-insensitive stringified value of the attribute
29
30// Output format:
31//
32// Since Gradle is chatty and often prints a "Welcome" banner even with -q option,
33// the only output lines that matter are:
34// - prefixed "SNYKECHO ": should be immediately printed as debug information by the caller
35// - prefixed "JSONDEPS ": JSON representation of the dependencies trees for all projects in the following format
36
37// interface JsonDepsScriptResult {
38// defaultProject: string;
39// projects: ProjectsDict;
40// allSubProjectNames: string[];
41// }
42// interface ProjectsDict {
43// [project: string]: GradleProjectInfo;
44// }
45
46// interface GradleProjectInfo {
47// depGraph: DepGraph;
48// gradleGraph: GradleGraph;
49// targetFile: string;
50// }
51// interface GradleGraph {
52// [id: string]: {
53// name: string;
54// version: string;
55// parentIds: string[];
56// };
57// }
58
59class GradleGraph {
60 Map<String, Map> nodes = [:]
61 String rootId
62
63 GradleGraph(String rootId) {
64 this.rootId = rootId
65 }
66
67 Map setNode(String key, Map value) {
68 if (!key || !value) {
69 return null
70 }
71
72 nodes.computeIfAbsent(key) { _ ->
73 ['name': value.name, 'version': value.version, 'parentIds': [] as Set]
74 }
75 }
76
77 void setEdge(String parentId, String childId) {
78 if (isInvalidEdge(parentId, childId)) {
79 return
80 }
81
82 def parentNode = nodes.computeIfAbsent(parentId) { _ -> createEmptyNode() }
83 def childNode = nodes.computeIfAbsent(childId) { _ -> createEmptyNode() }
84
85 if (!childNode.parentIds.contains(parentId)) {
86 childNode.parentIds.add(parentId)
87 }
88 }
89
90 private boolean isInvalidEdge(String parentId, String childId) {
91 parentId == null || childId == null || parentId == childId || (parentId != rootId && !nodes.containsKey(parentId))
92 }
93
94 private Map createEmptyNode() {
95 ['name': null, 'version': null, 'parentIds': [] as Set]
96 }
97}
98
99
100class Sha1Map {
101 def coordinates
102 Sha1Map() {
103 coordinates = [:]
104 }
105
106 def setCoordinate(key, value) {
107 this.coordinates[key] = value
108 }
109}
110
111def hash(File file) {
112 def md = java.security.MessageDigest.getInstance('SHA-1')
113 file.eachByte(1024 * 4) { buffer, len ->
114 md.update(buffer, 0, len)
115 }
116 return md.digest().encodeHex().toString()
117}
118
119def loadGraph(Iterable deps, GradleGraph graph, parentId, currentChain) {
120 deps.each { dep ->
121 dep.each { d ->
122 def childId = "${d.moduleGroup}:${d.moduleName}@${d.moduleVersion}"
123 if (!graph.nodes.get(childId)) {
124 def childDependency = ['name': "${d.moduleGroup}:${d.moduleName}", 'version': d.moduleVersion]
125 graph.setNode(childId, childDependency)
126 }
127 // In Gradle 2, there can be several instances of the same dependency present at each level,
128 // each for a different configuration. In this case, we need to merge the dependencies.
129 if (!currentChain.contains(childId) && d.children) {
130 currentChain.add(childId)
131 loadGraph(d.children, graph, childId, currentChain)
132 }
133 graph.setEdge(parentId, childId)
134 }
135 }
136}
137
138def loadSha1MapGraph(Iterable deps, GradleGraph graph, parentId, currentChain, sha1Map) {
139 deps.each { dep ->
140 dep.each { d ->
141 def childId = "${d.moduleGroup}:${d.moduleName}@${d.moduleVersion}"
142 if (!graph.nodes.get(childId)) {
143 def childDependency = ['name': "${d.moduleGroup}:${d.moduleName}", 'version': d.moduleVersion]
144 graph.setNode(childId, childDependency)
145
146 def moduleArtifacts = d.getModuleArtifacts()
147 if (moduleArtifacts[0] && moduleArtifacts[0].getExtension()) {
148 // TODO: filter out this project's modules from deps.
149 try {
150 def fileHash = hash(moduleArtifacts[0].getFile())
151 sha1Map.setCoordinate(fileHash, childId);
152 } catch (Exception e) {
153 debugLog("Failed to hash artifact ${moduleArtifacts[0]}")
154 }
155 }
156 }
157 // In Gradle 2, there can be several instances of the same dependency present at each level,
158 // each for a different configuration. In this case, we need to merge the dependencies.
159 if (!currentChain.contains(childId) && d.children) {
160 currentChain.add(childId)
161 loadSha1MapGraph(d.children, graph, childId, currentChain, sha1Map)
162 }
163 graph.setEdge(parentId, childId)
164 }
165 }
166}
167
168def getGradleGraph(Iterable deps) {
169 def rootId = 'root-node'
170 def graph = new GradleGraph(rootId)
171 def currentChain = new HashSet()
172 loadGraph(deps, graph, rootId, currentChain)
173
174 return graph.nodes
175}
176
177def getGradleGraphWithSha1Map(Iterable deps, Sha1Map sha1Map) {
178 if (!sha1Map) sha1Map = new Sha1Map()
179 def rootId = 'root-node'
180 def graph = new GradleGraph(rootId)
181 def currentChain = new HashSet()
182 loadSha1MapGraph(deps, graph, rootId, currentChain, sha1Map)
183
184 return [graph: graph.nodes, sha1Map: sha1Map]
185}
186
187def debugLog(msg) {
188 def debug = System.getenv('DEBUG') ?: ''
189 if (debug.length() > 0) {
190 println("SNYKECHO $msg")
191 }
192}
193
194def matchesAttributeFilter(conf, confAttrSpec) {
195 if (!conf.hasProperty('attributes')) {
196 // Gradle before version 3 does not support attributes
197 return true
198 }
199 def matches = true
200 def attrs = conf.attributes
201 attrs.keySet().each({ attr ->
202 def attrValueAsString = attrs.getAttribute(attr).toString().toLowerCase()
203 confAttrSpec.each({ keyValueFilter ->
204 // attr.name is a class name, e.g. com.android.build.api.attributes.BuildTypeAttr
205 if (attr.name.toLowerCase().contains(keyValueFilter[0]) && attrValueAsString != keyValueFilter[1]) {
206 matches = false
207 }
208 })
209 })
210 return matches
211}
212
213def findMatchingConfigs(confs, confNameFilter, confAttrSpec) {
214 def matching = confs.findAll({ it.name =~ confNameFilter })
215 if (confAttrSpec == null) {
216 // We don't have an attribute spec to match
217 return matching
218 }
219 return matching.findAll({ matchesAttributeFilter(it, confAttrSpec) })
220}
221
222def findProjectConfigs(proj, confNameFilter, confAttrSpec) {
223 def matching = findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
224 if (GradleVersion.current() < GradleVersion.version('3.0')) {
225 proj.configurations.each({ debugLog("conf.name=$it.name") })
226 return matching
227 }
228 proj.configurations.each({ debugLog("conf.name=$it.name; conf.canBeResolved=$it.canBeResolved; conf.canBeConsumed=$it.canBeConsumed") })
229 // We are looking for a configuration that `canBeResolved`, because it's a configuration for which
230 // we can compute a dependency graph and that contains all the necessary information for resolution to happen.
231 // See Gradle docs: https://docs.gradle.org/current/userguide/declaring_dependencies.html#sec:resolvable-consumable-configs
232 def resolvable = []
233 matching.each({ it ->
234 if (!it.canBeResolved) { return }
235 try {
236 // Try accessing resolvedConfiguration to filter out configs that may cause issues in strict lock mode
237 it.resolvedConfiguration
238 resolvable.add(it)
239 } catch (Exception ex) {
240 // Swallow the error
241 debugLog("Skipping config ${it.name} due to resolvedConfiguration error.")
242 }
243 })
244 debugLog("resolvableConfigs=$resolvable")
245 return resolvable
246}
247List getResolvedConfigs(resolvableConfigs){
248 List resolvedConfigs = []
249 resolvableConfigs.each({ config ->
250 ResolvedConfiguration resConf = config.getResolvedConfiguration()
251 debugLog("config `$config.name' resolution has errors: ${resConf.hasError()}")
252 if (!resConf.hasError()) {
253 resolvedConfigs.add(resConf)
254 debugLog("Fully resolved config `$config.name' with deps: $resConf.firstLevelModuleDependencies")
255 } else {
256 // even if some dependencies fail to resolve, we prefer a partial result to none
257 LenientConfiguration lenientConf = resConf.getLenientConfiguration()
258 debugLog("Partially resolved config `$config.name' with: $lenientConf.firstLevelModuleDependencies")
259 debugLog("Couldn't resolve: ${lenientConf.getUnresolvedModuleDependencies()}")
260 resolvedConfigs.add(lenientConf)
261 }
262 })
263 return resolvedConfigs
264}
265
266String formatPath(path) {
267 return path.replace(':', '/').replaceAll(~/(^\/+?)|(\/+$)/, '')
268}
269
270Boolean isRootPath(path){
271 return path == rootProject.path
272}
273
274// We are attaching this task to every project, as this is the only reliable way to run it
275// when we start with a subproject build.gradle. As a consequence, we need to make sure we
276// only ever run it once, for the "starting" project.
277def snykDepsConfExecuted = false
278allprojects { Project currProj ->
279 debugLog("Current project: $currProj.name")
280 String onlyProj = project.hasProperty('onlySubProject') ? onlySubProject : null
281 def confNameFilter = (project.hasProperty('configuration')
282 ? Pattern.compile(configuration, Pattern.CASE_INSENSITIVE)
283 : /.*/
284 )
285 def confAttrSpec = (project.hasProperty('confAttr')
286 ? confAttr.toLowerCase().split(',').collect { it.split(':') }
287 : null
288 )
289
290 task snykResolvedDepsJson {
291 doLast { task ->
292 if (snykDepsConfExecuted) {
293 return
294 }
295
296 snykDepsConfExecuted = true
297 debugLog('snykResolvedDepsJson task is executing via doLast')
298 // debugLog("onlyProj=$onlyProj; confNameFilter=$confNameFilter; confAttrSpec=$confAttrSpec")
299 // First pass: scan all configurations that match the attribute filter and collect all attributes
300 // from them, to use unambiguous values of the attributes on the merged configuration.
301 //
302 // Why we need to scan all sub-projects: if a project A depends on B, and only B has some
303 // configurations with attribute C, we still might need attribute C in our configuration
304 // when resolving the project A, so that it selects a concrete variant of dependency B.
305 def allConfigurationAttributes = [:] // Map<Attribute<?>, Set<?>>
306 def attributesAsStrings = [:] // Map<String, Set<string>>
307 rootProject.allprojects.each { proj ->
308 findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
309 .each { conf ->
310 def attrs = conf.attributes
311 attrs.keySet().toList().each({ attr ->
312 def value = attrs.getAttribute(attr)
313 if (!allConfigurationAttributes.containsKey(attr)) {
314 allConfigurationAttributes[attr] = new HashSet()
315 attributesAsStrings[attr.name] = new HashSet()
316 }
317 allConfigurationAttributes[attr].add(value)
318 attributesAsStrings[attr.name].add(value.toString())
319 })
320 }
321 }
322 // name of target project - either specified by --file or the root of a multi-module build
323 String defaultProjectName = task.project.name
324 // the path of the above, preferred over name because path is unique
325 String defaultProjectKey = isRootPath(task.project.path) ? defaultProjectName : formatPath(task.project.path)
326 // collect all subprojects names that are not target
327 List allSubProjectNames = []
328 allprojects
329 .findAll({ it.path != task.project.path })
330 .each({
331 String projKey = formatPath(it.path)
332 allSubProjectNames.add(projKey)
333 })
334 def shouldScanProject = {
335 onlyProj == null ||
336 (onlyProj == '.' && it.name == defaultProjectName) ||
337 it.name == onlyProj ||
338 formatPath(it.path) == onlyProj
339 }
340 def projectsDict = [:]
341
342 debugLog("defaultProjectName=$defaultProjectName; defaultProjectKey=$defaultProjectKey; allSubProjectNames=$allSubProjectNames")
343
344 // These will be used to suggest attribute filtering to the user if the scan fails
345 // due to ambiguous resolution of dependency variants
346 def jsonAttrs = JsonOutput.toJson(attributesAsStrings)
347 println("JSONATTRS $jsonAttrs")
348
349 rootProject.allprojects.findAll(shouldScanProject).each { proj ->
350 debugLog("processing project: name=$proj.name; path=$proj.path")
351
352 def resolvableConfigs = findProjectConfigs(proj, confNameFilter, confAttrSpec)
353 List resolvedConfigs = getResolvedConfigs(resolvableConfigs)
354
355 if (resolvedConfigs.isEmpty() && !resolvableConfigs.isEmpty()) {
356 throw new RuntimeException('Configurations: ' + resolvableConfigs.collect { it.name } +
357 ' for project ' + proj + ' could not be resolved.')
358 }
359 List nonemptyFirstLevelDeps = []
360 resolvedConfigs.each { nonemptyFirstLevelDeps.addAll(it.getFirstLevelModuleDependencies()) }
361
362 debugLog("non-empty first level deps for project `$proj.name': $nonemptyFirstLevelDeps")
363 debugLog('converting gradle graph to snyk-graph format')
364
365 def projGraph = getGradleGraph(nonemptyFirstLevelDeps)
366 String projKey = formatPath(proj.path)
367 // if project is root ":", it's formatted to "" - we can't have an empty string as a key so use default name
368 if (projKey == "") {
369 debugLog("project path is empty (proj.path=$proj.path)! will use defaultProjectName=$defaultProjectName")
370 projKey = defaultProjectKey
371 }
372
373 projectsDict[projKey] = [
374 'targetFile': findProject(proj.path).buildFile.toString(),
375 'gradleGraph': projGraph,
376 'projectVersion': proj.version.toString()
377 ]
378 }
379
380 def result = [
381 'defaultProject': defaultProjectName,
382 'defaultProjectKey': defaultProjectKey,
383 'projects': projectsDict,
384 'allSubProjectNames': allSubProjectNames
385 ]
386 def jsonDeps = JsonOutput.toJson(result)
387 println("JSONDEPS $jsonDeps")
388 }
389 }
390
391 task snykNormalizedResolvedDepsJson {
392
393 doLast { task ->
394 if (snykDepsConfExecuted) {
395 return
396 }
397
398 snykDepsConfExecuted = true
399 debugLog('snykNormalizedResolvedDepsJson task is executing via doLast')
400 // debugLog("onlyProj=$onlyProj; confNameFilter=$confNameFilter; confAttrSpec=$confAttrSpec")
401 // First pass: scan all configurations that match the attribute filter and collect all attributes
402 // from them, to use unambiguous values of the attributes on the merged configuration.
403 //
404 // Why we need to scan all sub-projects: if a project A depends on B, and only B has some
405 // configurations with attribute C, we still might need attribute C in our configuration
406 // when resolving the project A, so that it selects a concrete variant of dependency B.
407 def allConfigurationAttributes = [:] // Map<Attribute<?>, Set<?>>
408 def attributesAsStrings = [:] // Map<String, Set<string>>
409 rootProject.allprojects.each { proj ->
410 findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
411 .each { Configuration conf ->
412 def attrs = conf.attributes
413 attrs.keySet().each({ attr ->
414 def value = attrs.getAttribute(attr)
415 if (!allConfigurationAttributes.containsKey(attr)) {
416 allConfigurationAttributes[attr] = new HashSet()
417 attributesAsStrings[attr.name] = new HashSet()
418 }
419 allConfigurationAttributes[attr].add(value)
420 attributesAsStrings[attr.name].add(value.toString())
421 })
422 }
423 }
424
425 String defaultProjectName = task.project.name
426 String defaultProjectKey = isRootPath(task.project.path) ? defaultProjectName : formatPath(task.project.path)
427 List allSubProjectNames = []
428 allprojects
429 .findAll({ it.path != task.project.path })
430 .each({
431 String projKey = formatPath(it.path)
432 allSubProjectNames.add(projKey)
433 })
434 def shouldScanProject = {
435 onlyProj == null ||
436 (onlyProj == '.' && it.name == defaultProjectName) ||
437 it.name == onlyProj ||
438 formatPath(it.path) == onlyProj
439 }
440 def projectsDict = [:]
441
442 debugLog("defaultProjectName=$defaultProjectName; allSubProjectNames=$allSubProjectNames")
443
444 // These will be used to suggest attribute filtering to the user if the scan fails
445 // due to ambiguous resolution of dependency variants
446 def jsonAttrs = JsonOutput.toJson(attributesAsStrings)
447 println("JSONATTRS $jsonAttrs")
448
449 def sha1Map = new Sha1Map()
450
451 rootProject.allprojects.findAll(shouldScanProject).each { proj ->
452 debugLog("processing project: name=$proj.name; path=$proj.path")
453
454 def resolvableConfigs = findProjectConfigs(proj, confNameFilter, confAttrSpec)
455 List resolvedConfigs = getResolvedConfigs(resolvableConfigs)
456 if (!resolvableConfigs.isEmpty() && resolvedConfigs.isEmpty()) {
457 throw new RuntimeException('Configurations: ' + resolvableConfigs.collect { it.name } +
458 ' for project ' + proj + ' could not be resolved.')
459 }
460
461 List nonemptyFirstLevelDeps = []
462 resolvedConfigs.each { nonemptyFirstLevelDeps.addAll(it.getFirstLevelModuleDependencies()) }
463
464 debugLog("non-empty first level deps for project `$proj.name': $nonemptyFirstLevelDeps")
465 debugLog('converting gradle graph to snyk-graph format')
466
467 def projGraph = getGradleGraphWithSha1Map(nonemptyFirstLevelDeps, sha1Map)
468 String projKey = formatPath(proj.path)
469
470 if (projKey == "") {
471 debugLog("project path is empty (proj.path=$proj.path)! will use defaultProjectName=$defaultProjectName")
472 projKey = defaultProjectKey
473 }
474
475 projectsDict[projKey] = [
476 'targetFile': findProject(proj.path).buildFile.toString(),
477 'gradleGraph': projGraph.graph,
478 'projectVersion': proj.version.toString()
479 ]
480 }
481
482 def result = [
483 'defaultProject': defaultProjectName,
484 'defaultProjectKey': defaultProjectKey,
485 'projects': projectsDict,
486 'allSubProjectNames': allSubProjectNames,
487 'sha1Map': sha1Map.coordinates
488 ]
489
490 def jsonDeps = JsonOutput.toJson(result)
491 println("JSONDEPS $jsonDeps")
492 }
493 }
494}