-
Notifications
You must be signed in to change notification settings - Fork 565
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
15 changed files
with
652 additions
and
221 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,218 +1,12 @@ | ||
import * as fs from 'fs'; | ||
import * as path from 'path'; | ||
import * as https from 'https'; | ||
#!/usr/bin/env node | ||
|
||
async function protect() { | ||
let writingPatches = false; | ||
let writingTo: string; | ||
import protect from './lib'; | ||
|
||
// .snyk parsing => snyk-policy ( or js-yaml ) | ||
const patches = fs | ||
.readFileSync('.snyk', 'utf8') | ||
.split('\n') | ||
.filter((l) => l.length && !l.trimStart().startsWith('#')) | ||
.map(/^(\s*)(.*):(?:$| )+(.*)$/i.exec) | ||
.filter(Boolean) | ||
.reduce((acc, thing) => { | ||
const [, prefix, key, value] = thing as RegExpExecArray; | ||
if (writingPatches && prefix === '') { | ||
writingPatches = false; | ||
} else if (prefix === '' && key === 'patch' && value === '') { | ||
writingPatches = true; | ||
} else if (writingPatches) { | ||
if (prefix.length === 2) { | ||
writingTo = key; | ||
acc[key] = []; | ||
} else { | ||
if (key.startsWith('-')) { | ||
const destination = key | ||
.split('>') | ||
.pop() | ||
?.trim(); | ||
if (!acc[writingTo].includes(destination)) { | ||
acc[writingTo].push(destination); | ||
} | ||
} | ||
} | ||
} | ||
return acc; | ||
}, {}); | ||
|
||
const librariesOfInterest = Object.values(patches).flat(); | ||
const patchesOfInterest = Object.keys(patches); | ||
const foundLibraries: any[] = []; | ||
|
||
// parse node_modules | ||
function isDependencyToBePatched(folderName, folderPath) { | ||
if (!librariesOfInterest.includes(folderName)) { | ||
return false; | ||
} | ||
|
||
const packageJsonPath = path.resolve(folderPath, 'package.json'); | ||
if ( | ||
fs.existsSync(packageJsonPath) && | ||
fs.lstatSync(packageJsonPath).isFile() | ||
) { | ||
const { name, version } = JSON.parse( | ||
fs.readFileSync(packageJsonPath, 'utf8'), | ||
); | ||
if (librariesOfInterest.includes(name)) { | ||
foundLibraries.push({ | ||
name, | ||
version, | ||
folderPath, | ||
}); | ||
} | ||
} | ||
} | ||
function checkProject(pathToCheck) { | ||
if (fs.existsSync(pathToCheck) && fs.lstatSync(pathToCheck).isDirectory()) { | ||
const folderName = path.basename(pathToCheck); | ||
isDependencyToBePatched(folderName, pathToCheck); | ||
const folderNodeModules = path.resolve(pathToCheck, 'node_modules'); | ||
if ( | ||
fs.existsSync(folderNodeModules) && | ||
fs.lstatSync(folderNodeModules).isDirectory() | ||
) { | ||
fs.readdirSync(folderNodeModules).forEach((p) => { | ||
checkProject(path.resolve(folderNodeModules, p)); | ||
}); | ||
} | ||
} | ||
} | ||
checkProject(path.resolve(__dirname, '.')); | ||
|
||
// fetch patches => needle | ||
const httpsGet = (url: string, options: any = {}): Promise<any> => | ||
new Promise((resolve, reject) => { | ||
const parsedURL = new URL(url); | ||
const requestOptions = { | ||
...options, | ||
host: parsedURL.host, | ||
path: parsedURL.pathname, | ||
}; | ||
const request = https.get(requestOptions, (response) => { | ||
if ( | ||
response.statusCode && | ||
(response.statusCode < 200 || response.statusCode > 299) | ||
) { | ||
reject( | ||
new Error( | ||
'Failed to load page, status code: ' + response.statusCode, | ||
), | ||
); | ||
} | ||
const body: any[] = []; | ||
response.on('data', (chunk: any) => body.push(chunk)); | ||
response.on('end', () => | ||
resolve(options.json ? JSON.parse(body.join('')) : body.join('')), | ||
); | ||
}); | ||
request.on('error', reject); | ||
}); | ||
|
||
async function getPatches() { | ||
const snykPatches = {}; | ||
const checkedLibraries: any[] = []; | ||
for (const foundLibrary of foundLibraries) { | ||
const toCheck = `${foundLibrary.name}/${foundLibrary.version}`; | ||
if (!checkedLibraries.includes(toCheck)) { | ||
checkedLibraries.push(toCheck); | ||
const { issues } = await httpsGet( | ||
`https://snyk.io/api/v1/test/npm/${toCheck}`, | ||
{ | ||
json: true, | ||
headers: { | ||
Authorization: `token ${process.env.SNYK_TOKEN}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
}, | ||
); | ||
if (issues.vulnerabilities) { | ||
for (const vulnerability of issues.vulnerabilities) { | ||
if (patchesOfInterest.includes(vulnerability.id)) { | ||
snykPatches[vulnerability.package] = | ||
snykPatches[vulnerability.package] || []; | ||
const fetchedPatches = await Promise.all( | ||
vulnerability.patches.map(async (patch) => { | ||
return { | ||
...patch, | ||
diffs: await Promise.all( | ||
patch.urls.map(async (url) => httpsGet(url)), | ||
), | ||
}; | ||
}), | ||
); | ||
snykPatches[vulnerability.package] = [...fetchedPatches]; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
return snykPatches; | ||
} | ||
|
||
const snykPatches = await getPatches(); | ||
if (Object.keys(snykPatches).length === 0) { | ||
console.log('Nothing to patch, done'); | ||
return; | ||
} | ||
|
||
for (const [libToPatch, patches] of Object.entries(snykPatches)) { | ||
for (const place of foundLibraries.filter((l) => l.name === libToPatch)) { | ||
for (const patch of patches as any) { | ||
for (const patchDiff of (patch as any).diffs) { | ||
applyDiff(patchDiff, place.folderPath); | ||
} | ||
} | ||
} | ||
} | ||
async function main() { | ||
const projectPath = process.cwd(); | ||
await protect(projectPath); | ||
} | ||
|
||
// apply patches => patch apply || git apply || js-diff | ||
function applyDiff(patchDiff: string, baseFolder: string) { | ||
const patchFile = patchDiff.slice(patchDiff.search(/^--- a\//m)).split('\n'); | ||
const filename = path.resolve(baseFolder, patchFile[0].replace('--- a/', '')); | ||
|
||
const fileToPatch = fs.readFileSync(filename, 'utf-8').split('\n'); | ||
if (!patchFile[2]) { | ||
return; | ||
} | ||
const unparsedLineToPatch = /^@@ -(\d*),.*@@/.exec(patchFile[2]); | ||
if (!unparsedLineToPatch || !unparsedLineToPatch[1]) { | ||
return; | ||
} | ||
let lineToPatch = parseInt(unparsedLineToPatch[1], 10) - 2; | ||
|
||
const patchLines = patchFile.slice(3, patchFile.length - 2); | ||
|
||
for (const patchLine of patchLines) { | ||
lineToPatch += 1; | ||
switch (patchLine.charAt(0)) { | ||
case '-': | ||
fileToPatch.splice(lineToPatch, 1); | ||
break; | ||
|
||
case '+': | ||
fileToPatch.splice(lineToPatch, 0, patchLine); | ||
break; | ||
|
||
case ' ': | ||
if (fileToPatch[lineToPatch] !== patchLine.slice(1)) { | ||
console.log( | ||
'Expected\n line from local file\n', | ||
fileToPatch[lineToPatch], | ||
); | ||
console.log('\n to match patch line\n', patchLine.slice(1), '\n'); | ||
throw new Error( | ||
`File ${filename} to be patched does not match, not patching`, | ||
); | ||
} | ||
break; | ||
} | ||
} | ||
|
||
// fs.writeFileSync(filename, patchLines.join('\n')) | ||
if (require.main === module) { | ||
main(); | ||
} | ||
|
||
export default protect; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import * as fs from 'fs'; | ||
import * as path from 'path'; | ||
import { PhysicalModuleToPatch } from './types'; | ||
|
||
// Check if a physical module (given by folderPath) is a thing we want to patch and if it is, add it to the list of modules to patch | ||
// for a given path and | ||
function checkPhysicalModule( | ||
folderPath: string, | ||
librariesOfInterest: Readonly<string[]>, | ||
physicalModulesToPatch: PhysicalModuleToPatch[], | ||
) { | ||
const folderName = path.basename(folderPath); | ||
if (!librariesOfInterest.includes(folderName)) { | ||
return false; | ||
} | ||
|
||
const packageJsonPath = path.resolve(folderPath, 'package.json'); | ||
if ( | ||
fs.existsSync(packageJsonPath) && | ||
fs.lstatSync(packageJsonPath).isFile() | ||
) { | ||
const { name, version } = JSON.parse( | ||
fs.readFileSync(packageJsonPath, 'utf8'), | ||
); | ||
if (librariesOfInterest.includes(name)) { | ||
physicalModulesToPatch.push({ | ||
name, | ||
version, | ||
folderPath, | ||
} as PhysicalModuleToPatch); | ||
} | ||
} | ||
} | ||
|
||
// splelunk down the node_modules folder of a project given the project root directory looking for | ||
// physical modules which match our librariesOfInterest | ||
// we do not check for matching version at this point (that happens in getPatches) | ||
// calls checkPhysicalModule | ||
// calls itself recursively | ||
export function checkProject( | ||
pathToCheck: string, | ||
librariesOfInterest: Readonly<string[]>, | ||
physicalModulesToPatch: PhysicalModuleToPatch[], | ||
) { | ||
if (fs.existsSync(pathToCheck) && fs.lstatSync(pathToCheck).isDirectory()) { | ||
checkPhysicalModule( | ||
pathToCheck, | ||
librariesOfInterest, | ||
physicalModulesToPatch, | ||
); | ||
|
||
const folderNodeModules = path.resolve(pathToCheck, 'node_modules'); | ||
if ( | ||
fs.existsSync(folderNodeModules) && | ||
fs.lstatSync(folderNodeModules).isDirectory() | ||
) { | ||
fs.readdirSync(folderNodeModules).forEach((p) => { | ||
checkProject( | ||
path.resolve(folderNodeModules, p), | ||
librariesOfInterest, | ||
physicalModulesToPatch, | ||
); | ||
}); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import * as https from 'https'; | ||
import { PackageAndVersion } from './types'; | ||
|
||
export async function getPatches( | ||
foundPackages: PackageAndVersion[], | ||
patchesOfInterest: string[], | ||
) { | ||
const snykPatches = {}; | ||
const checkedLibraries: any[] = []; | ||
for (const foundLibrary of foundPackages) { | ||
const toCheck = `${foundLibrary.name}/${foundLibrary.version}`; | ||
if (!checkedLibraries.includes(toCheck)) { | ||
checkedLibraries.push(toCheck); | ||
|
||
const snykToken = process.env.SNYK_TOKEN || process.env.SNYK_API_KEY; | ||
if (!snykToken) { | ||
throw new Error('SNYK_TOKEN must be set'); | ||
} | ||
|
||
let apiBaseUrl = 'https://snyk.io/api'; | ||
if (process.env.SNYK_API) { | ||
if (process.env.SNYK_API.endsWith('/api')) { | ||
apiBaseUrl = process.env.SNYK_API; | ||
} else if (process.env.SNYK_API.endsWith('/api/v1')) { | ||
// snyk CI environment - we use `.../api/v1` though the norm is just `.../api` | ||
apiBaseUrl = process.env.SNYK_API.replace('/v1', ''); | ||
} else { | ||
console.log( | ||
'Malformed SNYK_API value. Using default: https://snyk.io/api', | ||
); | ||
} | ||
} | ||
|
||
const { issues } = await httpsGet( | ||
`${apiBaseUrl}/v1/test/npm/${toCheck}`, | ||
{ | ||
json: true, | ||
headers: { | ||
// TODO: remove after replacing with new API endpoint | ||
Authorization: `token ${snykToken}`, | ||
'Content-Type': 'application/json', | ||
}, | ||
}, | ||
); | ||
if (issues.vulnerabilities) { | ||
for (const vulnerability of issues.vulnerabilities) { | ||
if (patchesOfInterest.includes(vulnerability.id)) { | ||
snykPatches[vulnerability.package] = | ||
snykPatches[vulnerability.package] || []; | ||
const fetchedPatches = await Promise.all( | ||
vulnerability.patches.map(async (patch) => { | ||
return { | ||
...patch, | ||
diffs: await Promise.all( | ||
patch.urls.map(async (url) => httpsGet(url)), | ||
), | ||
}; | ||
}), | ||
); | ||
snykPatches[vulnerability.package] = [...fetchedPatches]; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
return snykPatches; | ||
} | ||
|
||
// fetch patches => needle | ||
export const httpsGet = async (url: string, options: any = {}): Promise<any> => | ||
new Promise((resolve, reject) => { | ||
const parsedURL = new URL(url); | ||
const requestOptions = { | ||
...options, | ||
host: parsedURL.host, | ||
path: parsedURL.pathname, | ||
}; | ||
const request = https.get(requestOptions, (response) => { | ||
if ( | ||
response.statusCode && | ||
(response.statusCode < 200 || response.statusCode > 299) | ||
) { | ||
reject( | ||
new Error('Failed to load page, status code: ' + response.statusCode), | ||
); | ||
} | ||
const body: any[] = []; | ||
response.on('data', (chunk: any) => body.push(chunk)); | ||
response.on('end', () => | ||
resolve(options.json ? JSON.parse(body.join('')) : body.join('')), | ||
); | ||
}); | ||
request.on('error', reject); | ||
}); |
Oops, something went wrong.