|
| 1 | +/** |
| 2 | + * @name Unvalidated Artifact Download |
| 3 | + * @description Download of artifact without integrity check. |
| 4 | + * @kind problem |
| 5 | + * @problem.severity warning |
| 6 | + * @security-severity 7.5 |
| 7 | + * @precision high |
| 8 | + * @id powershell/download-without-integrity-check |
| 9 | + * @tags security |
| 10 | + * external/cwe/cwe-494 |
| 11 | + * external/cwe/cwe-829 |
| 12 | + */ |
| 13 | + |
| 14 | +import powershell |
| 15 | +import semmle.code.powershell.dataflow.DataFlow |
| 16 | +import semmle.code.powershell.dataflow.TaintTracking |
| 17 | + |
| 18 | +/** Holds if `s` looks like a URL pointing at a trusted artifact host. */ |
| 19 | +bindingset[s] |
| 20 | +predicate isTrustedArtifactHost(string s) { |
| 21 | + s.matches([ |
| 22 | + "%github%", |
| 23 | + "%gitlab%", |
| 24 | + "%bitbucket%", |
| 25 | + "%sourceforge%", |
| 26 | + "%powershellgallery%", |
| 27 | + "%nuget%", |
| 28 | + "%npmjs%", |
| 29 | + "%pypi%", |
| 30 | + "%repo1.maven%", |
| 31 | + "%repo.maven.apache%", |
| 32 | + "%blob.core.windows%", |
| 33 | + "%amazonaws%", |
| 34 | + "%googleapis%", |
| 35 | + "%azure%", |
| 36 | + "%visualstudio%", |
| 37 | + "%jfrog%", |
| 38 | + "%artifactory%" |
| 39 | + ]) |
| 40 | +} |
| 41 | + |
| 42 | +/** A data-flow node that is tainted by a string constant looking like an artifact URL. */ |
| 43 | +class ArtifactUrl extends DataFlow::Node { |
| 44 | + ArtifactUrl() { |
| 45 | + exists(DataFlow::Node source, string s | |
| 46 | + TaintTracking::localTaint(source, this) and |
| 47 | + s = |
| 48 | + [ |
| 49 | + source.asExpr().getValue().asString(), |
| 50 | + source.asExpr().getExpr().(ExpandableStringExpr).getUnexpandedValue() |
| 51 | + ].toLowerCase() and |
| 52 | + isTrustedArtifactHost(s) and |
| 53 | + // Exclude API metadata endpoints (e.g. api.github.com/.../releases/latest), |
| 54 | + // which return JSON metadata rather than a downloadable artifact. |
| 55 | + not s.matches(["%api.github.com%", "%api.bitbucket.org%"]) |
| 56 | + ) |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * A call that downloads an artifact from a trusted host. |
| 62 | + * |
| 63 | + * This covers cmdlets and their aliases (`Invoke-WebRequest`/`iwr`, |
| 64 | + * `Invoke-RestMethod`/`irm`, `Start-BitsTransfer`), native download tools |
| 65 | + * (`curl`, `wget`, `azcopy`, `aria2c`) and the .NET `WebClient`/`HttpClient` |
| 66 | + * download methods. The URL may be passed as a named argument (e.g. `-Uri`, |
| 67 | + * `-Source`), positionally, or as a method argument. |
| 68 | + */ |
| 69 | +class DownloadCall extends DataFlow::CallNode { |
| 70 | + ArtifactUrl url; |
| 71 | + |
| 72 | + DownloadCall() { |
| 73 | + this.getAName() = |
| 74 | + [ |
| 75 | + // cmdlets and aliases |
| 76 | + "Invoke-WebRequest", "iwr", "Invoke-RestMethod", "irm", "Start-BitsTransfer", |
| 77 | + // native command-line download tools |
| 78 | + "curl", "curl.exe", "wget", "wget.exe", "azcopy", "azcopy.exe", "aria2c", "aria2c.exe", |
| 79 | + // .NET WebClient / HttpClient download methods |
| 80 | + "DownloadFile", "DownloadFileAsync", "DownloadFileTaskAsync", "DownloadData", |
| 81 | + "DownloadDataAsync", "DownloadDataTaskAsync", "DownloadString", "DownloadStringAsync", |
| 82 | + "GetByteArrayAsync", "GetStreamAsync" |
| 83 | + ] and |
| 84 | + url = this.getAnArgument() |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * Gets the argument that names the file the artifact is written to, if any. |
| 89 | + * Downloads that consume the response inline (e.g. `irm ... | iex`) have no |
| 90 | + * such argument. |
| 91 | + */ |
| 92 | + DataFlow::Node getOutFileArg() { |
| 93 | + result = |
| 94 | + this.getNamedArgument([ |
| 95 | + "outfile", "destination", "outputfile", "outpath", "literalpath", "path", "o" |
| 96 | + ]) |
| 97 | + or |
| 98 | + // WebClient.DownloadFile(url, destinationFile): the destination is the 2nd argument. |
| 99 | + this.getAName() = ["DownloadFile", "DownloadFileAsync", "DownloadFileTaskAsync"] and |
| 100 | + result = this.getArgument(1) |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +/** |
| 105 | + * A call that verifies the integrity of a file, by computing/comparing a hash |
| 106 | + * or by checking a signature. |
| 107 | + */ |
| 108 | +class IntegrityCheck extends DataFlow::CallNode { |
| 109 | + IntegrityCheck() { |
| 110 | + this.getAName() = |
| 111 | + [ |
| 112 | + "Get-FileHash", "gfh", // hash a file |
| 113 | + "certutil", "certutil.exe", // certutil -hashfile <file> SHA256 |
| 114 | + "ComputeHash", // [SHA256]::Create().ComputeHash(...) |
| 115 | + "Get-AuthenticodeSignature", "Test-FileCatalog", // signature / catalog checks |
| 116 | + "cosign", "cosign.exe", "gpg", "gpg.exe" // external signature verification |
| 117 | + ] |
| 118 | + } |
| 119 | + |
| 120 | + /** Gets an argument referring to the file whose integrity is being checked. */ |
| 121 | + DataFlow::Node getFile() { result = this.getAnArgument() } |
| 122 | +} |
| 123 | + |
| 124 | +module Conf implements DataFlow::ConfigSig { |
| 125 | + predicate isSource(DataFlow::Node source) { source = any(DownloadCall c).getOutFileArg() } |
| 126 | + |
| 127 | + predicate isSink(DataFlow::Node sink) { sink = any(IntegrityCheck c).getFile() } |
| 128 | +} |
| 129 | + |
| 130 | +module Flow = TaintTracking::Global<Conf>; |
| 131 | + |
| 132 | +/** Holds if the downloaded file `out` flows to an integrity check. */ |
| 133 | +predicate isVerified(DataFlow::Node out) { |
| 134 | + exists(Flow::PathNode source | |
| 135 | + source.getNode() = out and |
| 136 | + source.isSource() |
| 137 | + ) |
| 138 | +} |
| 139 | + |
| 140 | +from DownloadCall call, DataFlow::Node out |
| 141 | +where |
| 142 | + out = call.getOutFileArg() and |
| 143 | + not isVerified(out) |
| 144 | +select call, |
| 145 | + "This downloads an artifact without verifying its integrity (e.g. a hash or signature check)." |
0 commit comments