init.gradle 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import groovy.json.JsonOutput
  2. import java.util.regex.Pattern
  3. import java.util.regex.Matcher
  4. import org.gradle.util.GradleVersion
  5. // Snyk dependency resolution script for Gradle.
  6. // Tested on Gradle versions from v2.14 to v6.8.1
  7. // This script does the following: for all the projects in the build file,
  8. // generate a merged configuration of all the available configurations,
  9. // and then list the dependencies as a tree.
  10. // It's the responsibility of the caller to pick the project(s) they are
  11. // interested in from the results.
  12. // CLI usages:
  13. // gradle -q -I init.gradle snykResolvedDepsJson
  14. // gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=specificConf -PonlySubProject=sub-project
  15. // gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=confNameRegex -PconfAttr=buildtype:debug,usage:java-runtime
  16. // (-q to have clean output, -P supplies args as per https://stackoverflow.com/a/48370451)
  17. // confAttr parameter (supported only in Gradle 3+) is used to perform attribute-based dependency variant matching
  18. // (important for Android: https://developer.android.com/studio/build/dependencies#variant_aware)
  19. // Its value is a comma-separated list of key:value pairs. The "key" is a case-insensitive substring
  20. // of the class name of the attribute (e.g. "buildtype" would match com.android.build.api.attributes.BuildTypeAttr),
  21. // the value should be a case-insensitive stringified value of the attribute
  22. // Output format:
  23. //
  24. // Since Gradle is chatty and often prints a "Welcome" banner even with -q option,
  25. // the only output lines that matter are:
  26. // - prefixed "SNYKECHO ": should be immediately printed as debug information by the caller
  27. // - prefixed "JSONDEPS ": JSON representation of the dependencies trees for all projects in the following format
  28. // interface JsonDepsScriptResult {
  29. // defaultProject: string;
  30. // projects: ProjectsDict;
  31. // allSubProjectNames: string[];
  32. // }
  33. // interface ProjectsDict {
  34. // [project: string]: GradleProjectInfo;
  35. // }
  36. // interface GradleProjectInfo {
  37. // depGraph: DepGraph;
  38. // snykGraph: SnykGraph;
  39. // targetFile: string;
  40. // }
  41. // interface SnykGraph {
  42. // [id: string]: {
  43. // name: string;
  44. // version: string;
  45. // parentIds: string[];
  46. // };
  47. // }
  48. class SnykGraph {
  49. def nodes
  50. def rootId
  51. SnykGraph(rootId) {
  52. this.nodes = [:]
  53. this.rootId = rootId
  54. }
  55. def setNode(key, value) {
  56. if (!key) {
  57. return
  58. }
  59. if (this.nodes.get(key)) {
  60. return this.nodes.get(key)
  61. }
  62. if (!value) {
  63. return
  64. }
  65. def vertex = ['name': value.name, 'version': value.version, 'parentIds': [] as Set]
  66. this.nodes.put(key, vertex)
  67. return vertex
  68. }
  69. def setEdge(parentId, childId) {
  70. if (!parentId || !childId || parentId == childId) {
  71. return
  72. }
  73. // root-node will be the graphlib root that first-level deps will be attached to
  74. if (parentId != this.rootId) {
  75. def parentNode = this.setNode(parentId, null)
  76. if (!parentNode) {
  77. return
  78. }
  79. }
  80. def childNode = this.setNode(childId, null)
  81. if (!childNode || childNode.parentIds.contains(parentId)) {
  82. return
  83. }
  84. childNode.parentIds.add(parentId)
  85. }
  86. }
  87. def loadGraph(Iterable deps, SnykGraph graph, parentId, currentChain) {
  88. deps.each { dep ->
  89. dep.each { d ->
  90. def childId = "${d.moduleGroup}:${d.moduleName}@${d.moduleVersion}"
  91. if (!graph.nodes.get(childId)) {
  92. def childDependency = ['name': "${d.moduleGroup}:${d.moduleName}", 'version': d.moduleVersion]
  93. graph.setNode(childId, childDependency)
  94. }
  95. // In Gradle 2, there can be several instances of the same dependency present at each level,
  96. // each for a different configuration. In this case, we need to merge the dependencies.
  97. if (!currentChain.contains(childId) && d.children) {
  98. currentChain.add(childId)
  99. loadGraph(d.children, graph, childId, currentChain)
  100. }
  101. graph.setEdge(parentId, childId)
  102. }
  103. }
  104. }
  105. def getSnykGraph(Iterable deps) {
  106. def rootId = 'root-node'
  107. def graph = new SnykGraph(rootId)
  108. def currentChain = new HashSet()
  109. loadGraph(deps, graph, rootId, currentChain)
  110. return graph.nodes
  111. }
  112. def debugLog(msg) {
  113. def debug = System.getenv('DEBUG') ?: ''
  114. if (debug.length() > 0) {
  115. println("SNYKECHO $msg")
  116. }
  117. }
  118. def matchesAttributeFilter(conf, confAttrSpec) {
  119. if (!conf.hasProperty('attributes')) {
  120. // Gradle before version 3 does not support attributes
  121. return true
  122. }
  123. def matches = true
  124. def attrs = conf.attributes
  125. attrs.keySet().each({ attr ->
  126. def attrValueAsString = attrs.getAttribute(attr).toString().toLowerCase()
  127. confAttrSpec.each({ keyValueFilter ->
  128. // attr.name is a class name, e.g. com.android.build.api.attributes.BuildTypeAttr
  129. if (attr.name.toLowerCase().contains(keyValueFilter[0]) && attrValueAsString != keyValueFilter[1]) {
  130. matches = false
  131. }
  132. })
  133. })
  134. return matches
  135. }
  136. def findMatchingConfigs(confs, confNameFilter, confAttrSpec) {
  137. def matching = confs.findAll({ it.name =~ confNameFilter })
  138. if (matching.isEmpty() && !confs.isEmpty()) {
  139. throw new RuntimeException('Matching configurations ' + confNameFilter + ' were not found')
  140. }
  141. if (confAttrSpec == null) {
  142. // We don't have an attribute spec to match
  143. return matching
  144. }
  145. return matching.findAll({ matchesAttributeFilter(it, confAttrSpec) })
  146. }
  147. def findProjectConfigs(proj, confNameFilter, confAttrSpec) {
  148. def matching = findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
  149. if (GradleVersion.current() < GradleVersion.version('3.0')) {
  150. proj.configurations.each({ debugLog("conf.name=$it.name") })
  151. return matching
  152. }
  153. proj.configurations.each({ debugLog("conf.name=$it.name; conf.canBeResolved=$it.canBeResolved; conf.canBeConsumed=$it.canBeConsumed") })
  154. // We are looking for a configuration that `canBeResolved`, because it's a configuration for which
  155. // we can compute a dependency graph and that contains all the necessary information for resolution to happen.
  156. // See Gradle docs: https://docs.gradle.org/current/userguide/declaring_dependencies.html#sec:resolvable-consumable-configs
  157. def resolvable = []
  158. matching.each({ it ->
  159. if (!it.canBeResolved) { return }
  160. try {
  161. // Try accessing resolvedConfiguration to filter out configs that may cause issues in strict lock mode
  162. it.resolvedConfiguration
  163. resolvable.add(it)
  164. } catch (Exception ex) {
  165. // Swallow the error
  166. debugLog("Skipping config ${it.name} due to resolvedConfiguration error.")
  167. }
  168. })
  169. debugLog("resolvableConfigs=$resolvable")
  170. return resolvable
  171. }
  172. // We are attaching this task to every project, as this is the only reliable way to run it
  173. // when we start with a subproject build.gradle. As a consequence, we need to make sure we
  174. // only ever run it once, for the "starting" project.
  175. def snykDepsConfExecuted = false
  176. allprojects { currProj ->
  177. debugLog("Current project: $currProj.name")
  178. task snykResolvedDepsJson {
  179. def onlyProj = project.hasProperty('onlySubProject') ? onlySubProject : null
  180. def confNameFilter = (project.hasProperty('configuration')
  181. ? Pattern.compile(configuration, Pattern.CASE_INSENSITIVE)
  182. : /.*/
  183. )
  184. def confAttrSpec = (project.hasProperty('confAttr')
  185. ? confAttr.toLowerCase().split(',').collect { it.split(':') }
  186. : null
  187. )
  188. doLast { task ->
  189. if (snykDepsConfExecuted) {
  190. return
  191. }
  192. debugLog('snykResolvedDepsJson task is executing via doLast')
  193. // debugLog("onlyProj=$onlyProj; confNameFilter=$confNameFilter; confAttrSpec=$confAttrSpec")
  194. // First pass: scan all configurations that match the attribute filter and collect all attributes
  195. // from them, to use unambiguous values of the attributes on the merged configuration.
  196. //
  197. // Why we need to scan all sub-projects: if a project A depends on B, and only B has some
  198. // configurations with attribute C, we still might need attribute C in our configuration
  199. // when resolving the project A, so that it selects a concrete variant of dependency B.
  200. def allConfigurationAttributes = [:] // Map<Attribute<?>, Set<?>>
  201. def attributesAsStrings = [:] // Map<String, Set<string>>
  202. rootProject.allprojects.each { proj ->
  203. findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
  204. .each { conf ->
  205. if (!conf.hasProperty('attributes')) {
  206. // Gradle before version 3 does not support attributes
  207. return
  208. }
  209. def attrs = conf.attributes
  210. attrs.keySet().each({ attr ->
  211. def value = attrs.getAttribute(attr)
  212. if (!allConfigurationAttributes.containsKey(attr)) {
  213. allConfigurationAttributes[attr] = new HashSet()
  214. attributesAsStrings[attr.name] = new HashSet()
  215. }
  216. allConfigurationAttributes[attr].add(value)
  217. attributesAsStrings[attr.name].add(value.toString())
  218. })
  219. }
  220. }
  221. def defaultProjectName = task.project.name
  222. def allSubProjectNames = []
  223. def seenSubprojects = [:]
  224. allprojects
  225. .findAll({ it.name != defaultProjectName })
  226. .each({
  227. def projKey = it.name
  228. if (seenSubprojects.get(projKey)) {
  229. projKey = it.path.replace(':', '/').replaceAll(~/(^\/+?)|(\/+$)/, '')
  230. if (projKey == "") {
  231. projKey = defaultProjectName
  232. }
  233. }
  234. allSubProjectNames.add(projKey)
  235. seenSubprojects[projKey] = true
  236. })
  237. def shouldScanProject = {
  238. onlyProj == null ||
  239. (onlyProj == '.' && it.name == defaultProjectName) ||
  240. it.name == onlyProj
  241. }
  242. def projectsDict = [:]
  243. debugLog("defaultProjectName=$defaultProjectName; allSubProjectNames=$allSubProjectNames")
  244. // These will be used to suggest attribute filtering to the user if the scan fails
  245. // due to ambiguous resolution of dependency variants
  246. def jsonAttrs = JsonOutput.toJson(attributesAsStrings)
  247. println("JSONATTRS $jsonAttrs")
  248. rootProject.allprojects.findAll(shouldScanProject).each { proj ->
  249. debugLog("processing project: name=$proj.name; path=$proj.path")
  250. def resolvableConfigs = findProjectConfigs(proj, confNameFilter, confAttrSpec)
  251. def resolvedConfigs = []
  252. resolvableConfigs.each({ config ->
  253. def resConf = config.resolvedConfiguration
  254. debugLog("config `$config.name' resolution has errors: ${resConf.hasError()}")
  255. if (!resConf.hasError()) {
  256. resolvedConfigs.add(resConf)
  257. debugLog("first level module dependencies for config `$config.name': $resConf.firstLevelModuleDependencies")
  258. }
  259. })
  260. if (resolvedConfigs.isEmpty() && !resolvableConfigs.isEmpty()) {
  261. throw new RuntimeException('Configurations: ' + resolvableConfigs.collect { it.name } +
  262. ' for project ' + proj + ' could not be resolved.')
  263. }
  264. def nonemptyFirstLevelDeps = resolvedConfigs.resolvedConfiguration.firstLevelModuleDependencies.findAll({ !it.isEmpty() })
  265. debugLog("non-empty first level deps for project `$proj.name': $nonemptyFirstLevelDeps")
  266. debugLog('converting gradle graph to snyk-graph format')
  267. def projGraph = getSnykGraph(nonemptyFirstLevelDeps)
  268. def projKey = proj.name
  269. if (projectsDict.get(projKey)) {
  270. debugLog("The deps dict already has a project with key `$projKey'; using the full path")
  271. projKey = proj.path.replace(':', '/').replaceAll(~/(^\/+?)|(\/+$)/, '')
  272. if (projKey == "") {
  273. debugLog("project path is empty (proj.path=$proj.path)! will use defaultProjectName=$defaultProjectName")
  274. projKey = defaultProjectName
  275. }
  276. }
  277. projectsDict[projKey] = [
  278. 'targetFile': findProject(proj.path).buildFile.toString(),
  279. 'snykGraph': projGraph,
  280. 'projectVersion': proj.version.toString()
  281. ]
  282. }
  283. def result = [
  284. 'defaultProject': defaultProjectName,
  285. 'projects': projectsDict,
  286. 'allSubProjectNames': allSubProjectNames
  287. ]
  288. def jsonDeps = JsonOutput.toJson(result)
  289. println("JSONDEPS $jsonDeps")
  290. snykDepsConfExecuted = true
  291. }
  292. }
  293. }