1'use strict' 2 3const parser = require('postcss-selector-parser') 4 5const arrayDelimiter = Symbol('arrayDelimiter') 6 7const escapeSlashes = str => 8 str.replace(/\//g, '\\/') 9 10const unescapeSlashes = str => 11 str.replace(/\\\//g, '/') 12 13// recursively fixes up any :attr pseudo-class found 14const fixupAttr = astNode => { 15 const properties = [] 16 const matcher = {} 17 for (const selectorAstNode of astNode.nodes) { 18 const [firstAstNode] = selectorAstNode.nodes 19 if (firstAstNode.type === 'tag') { 20 properties.push(firstAstNode.value) 21 } 22 } 23 24 const lastSelectorAstNode = astNode.nodes.pop() 25 const [attributeAstNode] = lastSelectorAstNode.nodes 26 27 if (attributeAstNode.value === ':attr') { 28 const appendParts = fixupAttr(attributeAstNode) 29 properties.push(arrayDelimiter, ...appendParts.lookupProperties) 30 matcher.qualifiedAttribute = appendParts.attributeMatcher.qualifiedAttribute 31 matcher.operator = appendParts.attributeMatcher.operator 32 matcher.value = appendParts.attributeMatcher.value 33 34 // backwards compatibility 35 matcher.attribute = appendParts.attributeMatcher.attribute 36 37 if (appendParts.attributeMatcher.insensitive) { 38 matcher.insensitive = true 39 } 40 } else { 41 if (attributeAstNode.type !== 'attribute') { 42 throw Object.assign( 43 new Error('`:attr` pseudo-class expects an attribute matcher as the last value'), 44 { code: 'EQUERYATTR' } 45 ) 46 } 47 48 matcher.qualifiedAttribute = unescapeSlashes(attributeAstNode.qualifiedAttribute) 49 matcher.operator = attributeAstNode.operator 50 matcher.value = attributeAstNode.value 51 52 // backwards compatibility 53 matcher.attribute = matcher.qualifiedAttribute 54 55 if (attributeAstNode.insensitive) { 56 matcher.insensitive = true 57 } 58 } 59 60 astNode.lookupProperties = properties 61 astNode.attributeMatcher = matcher 62 astNode.nodes.length = 0 63 return astNode 64} 65 66// fixed up nested pseudo nodes will have their internal selectors moved 67// to a new root node that will be referenced by the `nestedNode` property, 68// this tweak makes it simpler to reuse `retrieveNodesFromParsedAst` to 69// recursively parse and extract results from the internal selectors 70const fixupNestedPseudo = astNode => { 71 // create a new ast root node and relocate any children 72 // selectors of the current ast node to this new root 73 const newRootNode = parser.root() 74 astNode.nestedNode = newRootNode 75 newRootNode.nodes = [...astNode.nodes] 76 77 // clean up the ast by removing the children nodes from the 78 // current ast node while also cleaning up their `parent` refs 79 astNode.nodes.length = 0 80 for (const currAstNode of newRootNode.nodes) { 81 currAstNode.parent = newRootNode 82 } 83 84 // recursively fixup nodes of any nested selector 85 transformAst(newRootNode) 86} 87 88// :semver(<version|range|selector>, [version|range|selector], [function]) 89// note: the first or second parameter must be a static version or range 90const fixupSemverSpecs = astNode => { 91 // if we have three nodes, the last is the semver function to use, pull that out first 92 if (astNode.nodes.length === 3) { 93 const funcNode = astNode.nodes.pop().nodes[0] 94 if (funcNode.type === 'tag') { 95 astNode.semverFunc = funcNode.value 96 } else if (funcNode.type === 'string') { 97 // a string is always in some type of quotes, we don't want those so slice them off 98 astNode.semverFunc = funcNode.value.slice(1, -1) 99 } else { 100 // anything that isn't a tag or a string isn't a function name 101 throw Object.assign( 102 new Error('`:semver` pseudo-class expects a function name as last value'), 103 { code: 'ESEMVERFUNC' } 104 ) 105 } 106 } 107 108 // now if we have 1 node, it's a static value 109 // istanbul ignore else 110 if (astNode.nodes.length === 1) { 111 const semverNode = astNode.nodes.pop() 112 astNode.semverValue = semverNode.nodes.reduce((res, next) => `${res}${String(next)}`, '') 113 } else if (astNode.nodes.length === 2) { 114 // and if we have two nodes, one of them is a static value and we need to determine which it is 115 for (let i = 0; i < astNode.nodes.length; ++i) { 116 const type = astNode.nodes[i].nodes[0].type 117 // the type of the first child may be combinator for ranges, such as >14 118 if (type === 'tag' || type === 'combinator') { 119 const semverNode = astNode.nodes.splice(i, 1)[0] 120 astNode.semverValue = semverNode.nodes.reduce((res, next) => `${res}${String(next)}`, '') 121 astNode.semverPosition = i 122 break 123 } 124 } 125 126 if (typeof astNode.semverValue === 'undefined') { 127 throw Object.assign( 128 new Error('`:semver` pseudo-class expects a static value in the first or second position'), 129 { code: 'ESEMVERVALUE' } 130 ) 131 } 132 } 133 134 // if we got here, the last remaining child should be attribute selector 135 if (astNode.nodes.length === 1) { 136 fixupAttr(astNode) 137 } else { 138 // if we don't have a selector, we default to `[version]` 139 astNode.attributeMatcher = { 140 insensitive: false, 141 attribute: 'version', 142 qualifiedAttribute: 'version', 143 } 144 astNode.lookupProperties = [] 145 } 146 147 astNode.nodes.length = 0 148} 149 150const fixupTypes = astNode => { 151 const [valueAstNode] = astNode.nodes[0].nodes 152 const { value } = valueAstNode || {} 153 astNode.typeValue = value 154 astNode.nodes.length = 0 155} 156 157const fixupPaths = astNode => { 158 astNode.pathValue = unescapeSlashes(String(astNode.nodes[0])) 159 astNode.nodes.length = 0 160} 161 162const fixupOutdated = astNode => { 163 if (astNode.nodes.length) { 164 astNode.outdatedKind = String(astNode.nodes[0]) 165 astNode.nodes.length = 0 166 } 167} 168 169const fixupVuln = astNode => { 170 const vulns = [] 171 if (astNode.nodes.length) { 172 for (const selector of astNode.nodes) { 173 const vuln = {} 174 for (const node of selector.nodes) { 175 if (node.type !== 'attribute') { 176 throw Object.assign( 177 new Error(':vuln pseudo-class only accepts attribute matchers or "cwe" tag'), 178 { code: 'EQUERYATTR' } 179 ) 180 } 181 if (!['severity', 'cwe'].includes(node._attribute)) { 182 throw Object.assign( 183 new Error(':vuln pseudo-class only matches "severity" and "cwe" attributes'), 184 { code: 'EQUERYATTR' } 185 ) 186 } 187 if (!node.operator) { 188 node.operator = '=' 189 node.value = '*' 190 } 191 if (node.operator !== '=') { 192 throw Object.assign( 193 new Error(':vuln pseudo-class attribute selector only accepts "=" operator', node), 194 { code: 'EQUERYATTR' } 195 ) 196 } 197 if (!vuln[node._attribute]) { 198 vuln[node._attribute] = [] 199 } 200 vuln[node._attribute].push(node._value) 201 } 202 vulns.push(vuln) 203 } 204 astNode.vulns = vulns 205 astNode.nodes.length = 0 206 } 207} 208 209// a few of the supported ast nodes need to be tweaked in order to properly be 210// interpreted as proper arborist query selectors, namely semver ranges from 211// both ids and :semver pseudo-class selectors need to be translated from what 212// are usually multiple ast nodes, such as: tag:1, class:.0, class:.0 to a 213// single `1.0.0` value, other pseudo-class selectors also get preprocessed in 214// order to make it simpler to execute later when traversing each ast node 215// using rootNode.walk(), such as :path, :type, etc. transformAst handles all 216// these modifications to the parsed ast by doing an extra, initial traversal 217// of the parsed ast from the query and modifying the parsed nodes accordingly 218const transformAst = selector => { 219 selector.walk((nextAstNode) => { 220 switch (nextAstNode.value) { 221 case ':attr': 222 return fixupAttr(nextAstNode) 223 case ':is': 224 case ':has': 225 case ':not': 226 return fixupNestedPseudo(nextAstNode) 227 case ':path': 228 return fixupPaths(nextAstNode) 229 case ':semver': 230 return fixupSemverSpecs(nextAstNode) 231 case ':type': 232 return fixupTypes(nextAstNode) 233 case ':outdated': 234 return fixupOutdated(nextAstNode) 235 case ':vuln': 236 return fixupVuln(nextAstNode) 237 } 238 }) 239} 240 241const queryParser = (query) => { 242 // if query is an empty string or any falsy 243 // value, just returns an empty result 244 if (!query) { 245 return [] 246 } 247 248 return parser(transformAst) 249 .astSync(escapeSlashes(query), { lossless: false }) 250} 251 252module.exports = { 253 parser: queryParser, 254 arrayDelimiter, 255} 256