@eurekadevsecops/radar
Version:
Radar is an open-source orchestrator of security scanners.
423 lines (358 loc) • 12.2 kB
JavaScript
const { execSync } = require('node:child_process')
const hostedGitInfo = require('hosted-git-info')
const { isGitlabCi } = require('../ci')
function isAzureDevOpsUrl(originUrl) {
const knownAzureDomains = [
'dev.azure.com',
'visualstudio.com',
'ssh.dev.azure.com',
'vs-ssh.visualstudio.com'
]
return knownAzureDomains.some((url) => originUrl.includes(url))
}
function isBitbucketUrl(originUrl) {
if (!originUrl) return false
return originUrl.toLowerCase().includes('bitbucket.org')
}
function isGitlabHostUrl(originUrl) {
if (!originUrl) return false
const sshMatch = parseSshUrl(originUrl)
if (sshMatch) {
return sshMatch.host.toLowerCase().includes('gitlab')
}
if (!/^https?:\/\//i.test(originUrl)) return false
try {
const url = new URL(originUrl)
return url.hostname.toLowerCase().includes('gitlab')
} catch (error) {
return false
}
}
function parseSshUrl(originUrl, { user } = {}) {
if (/^https?:\/\//i.test(originUrl)) return null
const match = originUrl.match(/^(?:ssh:\/\/)?([^@]+)@([^:/]+)(?::|\/)(.+)$/)
if (!match) return null
if (user && match[1] !== user) return null
return {
user: match[1],
host: match[2],
path: match[3]
}
}
/**
* BitBucket formats:
* - `http://<user>@bitbucket.org/<workspace>/<repo>`
* - `https://bitbucket.org/<workspace>/<repo>`
* - `git@bitbucket.org:<workspace>/<repo>.git`
* - `ssh://git@bitbucket.org/<workspace>/<repo>.git`
* - The remote URL for BitBucket repositories may omit or include the .git suffix.
*/
function parseBitbucketUrl(originUrl) {
if (!originUrl) return null
const parsePathParts = (rawPath) =>
rawPath.split('/').filter((p) => p)
const sshMatch = parseSshUrl(originUrl, { user: 'git' })
if (sshMatch) {
const host = sshMatch.host
const pathParts = parsePathParts(sshMatch.path)
if (pathParts.length < 2) return null
const user = pathParts[0]
const project = pathParts[1].replace(/\.git$/i, '')
if (!user || !project) return null
const httpsUrl = `https://${host}/${user}/${project}`
return {
https: () => httpsUrl,
type: 'bitbucket',
domain: host,
user,
project
}
}
if (!/^https?:\/\//i.test(originUrl)) return null
let url
try {
url = new URL(originUrl)
} catch (error) {
return null
}
const pathParts = parsePathParts(url.pathname)
if (pathParts.length < 2) return null
const user = pathParts[0]
const project = pathParts[1].replace(/\.git$/i, '')
if (!user || !project) return null
const httpsUrl = `https://${url.hostname}/${user}/${project}`
return {
https: () => httpsUrl,
type: 'bitbucket',
domain: url.hostname,
user,
project
}
}
// Fallback for GitLab-style remotes that hosted-git-info does not recognize.
function parseGitlabSelfManagedUrl(originUrl) {
if (!originUrl) return null
const type = isGitlabCi() || isGitlabHostUrl(originUrl) ? 'gitlab' : 'git'
const parsePathParts = (rawPath) =>
rawPath.split('/').filter((p) => p)
const buildInfo = ({ domain, pathParts, httpsBaseUrl }) => {
if (pathParts.length < 2) return null
const project = pathParts.at(-1).replace(/\.git$/i, '')
const user = pathParts.slice(0, -1).join('/')
if (!user || !project) return null
const httpsUrl = `${httpsBaseUrl}/${user}/${project}`
return {
https: () => httpsUrl,
type,
domain,
user,
project
}
}
const sshMatch = parseSshUrl(originUrl)
if (sshMatch) {
return buildInfo({
domain: sshMatch.host,
pathParts: parsePathParts(sshMatch.path),
httpsBaseUrl: `https://${sshMatch.host}`
})
}
if (!/^https?:\/\//i.test(originUrl)) return null
let url
try {
url = new URL(originUrl)
} catch (error) {
return null
}
return buildInfo({
domain: url.host,
pathParts: parsePathParts(url.pathname),
httpsBaseUrl: url.origin
})
}
function parseFallbackGitInfoFromUrl(originUrl) {
if (isGitlabCi() || isGitlabHostUrl(originUrl)) {
return parseGitlabSelfManagedUrl(originUrl)
}
}
/**
* Azure DevOps formats:
* - `https://TOKEN@dev.azure.com/<org>/<project>/_git/<repo>`
* - `https://pat:TOKEN@dev.azure.com/<org>/<project>/_git/<repo>` (the "pat" here can be any arbitrary string)
* - `git@ssh.dev.azure.com:v3/<org>/<project>/<repo>`
* - `ssh://git@ssh.dev.azure.com/v3/<org>/<project>/<repo>`
* - `git@vs-ssh.visualstudio.com:v3/<org>/<project>/<repo>`
*/
function parseAzureDevOpsUrl(originUrl) {
const decodeComponentURI = (value) => {
try {
return decodeURIComponent(value)
} catch (error) {
throw new Error(`failed decoding Azure DevOps URL component: ${error.message}`)
}
}
const parsePathParts = (rawPath) =>
rawPath
.split('/')
.filter((p) => p)
.map((part) => decodeComponentURI(part))
const sshMatch = parseSshUrl(originUrl, { user: 'git' })
if (sshMatch) {
const host = sshMatch.host
let pathParts = parsePathParts(sshMatch.path)
if (pathParts[0] === 'v3') pathParts = pathParts.slice(1)
if (pathParts.length < 3) {
throw new Error(`Invalid Azure DevOps URL format: ${originUrl}`)
}
const [org, project, repo] = pathParts
const https =
host.includes('visualstudio.com')
? `https://${org}.visualstudio.com/${project}/_git/${repo}`
: `https://dev.azure.com/${org}/${project}/_git/${repo}`
return {
https: () => https,
type: 'azure',
domain: host,
// project name
user: project,
// repo name
project: repo
}
}
// Strip credentials from URL
const cleanUrl = originUrl.replace(/https:\/\/([^@:]+:)?[^@]+@/, 'https://')
const url = new URL(cleanUrl)
const pathParts = parsePathParts(url.pathname)
if (pathParts.length < 4 || pathParts[2] !== '_git') {
throw new Error(`Invalid Azure DevOps URL format: ${originUrl}`)
}
return {
https: () => cleanUrl,
type: 'azure',
domain: url.hostname,
// project name
user: `${pathParts[0]}/${pathParts[1]}`,
// repo name
project: pathParts[3]
}
}
function parseGitInfoFromUrl(originUrl) {
if (isAzureDevOpsUrl(originUrl)) {
return parseAzureDevOpsUrl(originUrl)
}
if (isBitbucketUrl(originUrl)) {
const bitbucketInfo = parseBitbucketUrl(originUrl)
if (bitbucketInfo) return bitbucketInfo
}
const hostedInfo = hostedGitInfo.fromUrl(originUrl, { noGitPlus: true })
if (hostedInfo) return hostedInfo
return parseFallbackGitInfoFromUrl(originUrl)
}
function metadata(folder) {
try {
// Determine if we're scanning a valid git repo.
const isGitRepo = execSync('git rev-parse --is-inside-work-tree', { cwd: folder }).toString().trim()
if (isGitRepo !== 'true') {
return { type: 'folder' }
}
// Get the repo name and owner.
const originUrl = execSync('git config --get remote.origin.url', { cwd: folder }).toString().trim()
const info = parseGitInfoFromUrl(originUrl)
if (!info || !info.user || !info.project || !info.domain || !info.https) {
throw new Error(`unsupported remote.origin.url: ${originUrl}`)
}
const ownerPath = info.user.split('/')
// Get the branch name.
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: folder }).toString().trim()
// Get the commit identifier and timestamp.
const shortCommitId = execSync('git rev-parse --short HEAD', { cwd: folder }).toString().trim()
const fullCommitId = execSync('git rev-parse HEAD', { cwd: folder }).toString().trim()
const commitTime = execSync('git show -s --format=%cI HEAD', { cwd: folder }).toString().trim()
// Get the tags for the current commit.
let tags = execSync('git tag --points-at HEAD', { cwd: folder }).toString().trim()
tags = '["' + tags.split('\n').join('","') + '"]'
try {
tags = JSON.parse(tags).filter(tag => tag)
}
catch (error) {
throw new Error(`failed parsing repo tags: ${error.message}`)
}
// Get the list of unique repo contributors (authors and committers).
const template = '%cn:%ce%n%an:%ae%n'
let contributors = execSync(`git log --pretty=${template} | sort -u`, { cwd: folder }).toString().trim()
try {
contributors = contributors
.split('\n')
.map(c => {
const ne = (c + '').split(':')
return {
name: ne?.at(0) ?? '',
email: ne?.at(1) ?? ''
}
})
.filter(c => isValidEmail(c.email))
}
catch (error) {
throw new Error(`failed parsing repo contributors: ${error.message}`)
}
const script = `MAX_LENGTH=4;
git rev-list --abbrev=4 --abbrev-commit --all | \
( while read -r line; do
if [ \${#line} -gt $MAX_LENGTH ]; then
MAX_LENGTH=\${#line};
fi
done && printf %s\\\\n "$MAX_LENGTH"
)`
const abbrevs = Number(execSync(script, { cwd: folder }).toString().trim())
/*
// Get the total lines of code in the repo.
const loc = execSync('git ls-files -z ${1} | xargs -0 cat | wc -l', { cwd: folder }).toString().trim()
*/
// Return the repo metadata.
const metadata = {
type: 'git',
repo: {
url: {
origin: originUrl,
https: info.https()
},
source: {
type: info.type,
domain: info.domain
},
owner: ownerPath[0],
path: ownerPath.slice(1).join('/'),
name: info.project,
abbrevs,
contributors
},
commit: {
id: fullCommitId,
time: commitTime,
branch,
tags
}
}
// Validate repo metadata.
if (!metadata.repo.url.origin) throw new Error('remote.origin.url not present')
if (!metadata.repo.url.https) throw new Error('remote.origin.url (https) not present')
if (!metadata.repo.source.type) throw new Error('unable to determine repository type')
if (!metadata.repo.source.domain) throw new Error('unable to determine repository domain')
if (!metadata.repo.owner) throw new Error('unknown repo owner')
if (!metadata.repo.name) throw new Error('unknown repo name')
if (!metadata.repo.abbrevs) throw new Error('unable to determine number of significant digits for commit IDs')
if (!metadata.repo.contributors) throw new Error('no repository contributors present')
if (!metadata.commit.id) throw new Error('commit ID not present')
if (!metadata.commit.time) throw new Error('commit time not present')
if (!metadata.commit.branch) throw new Error('branch not present')
return metadata
} catch (error) {
return {
type: 'error',
error: {
code: 'E_GIT_METADATA',
details: error
}
}
}
}
function root(folder) {
try {
// Get the full OS path to the root of the repo.
const root = execSync('git rev-parse --show-toplevel', { cwd: folder }).toString().trim()
return root
} catch (error) {
return {
type: 'error',
error: {
code: 'E_GIT_METADATA',
details: error
}
}
}
}
/**
* Thanks to:
* http://fightingforalostcause.net/misc/2006/compare-email-regex.php
* http://thedailywtf.com/Articles/Validating_Email_Addresses.aspx
* http://stackoverflow.com/questions/201323/what-is-the-best-regular-expression-for-validating-email-addresses/201378#201378
* https://en.wikipedia.org/wiki/Email_address The format of an email address is local-part@domain, where the
* local part may be up to 64 octets long and the domain may have a maximum of 255 octets.[4]
*/
function isValidEmail(email) {
if (!email) return false
const parts = email.split('@')
if (parts.length !== 2) return false
const account = parts[0]
if (account.length > 64) return false
const address = parts[1]
if (address.length > 255) return false
const domainParts = address.split('.')
if (domainParts.some((part) => part.length > 63 )) return false
const emailRE = /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
return emailRE.test(email)
}
module.exports = {
metadata,
root
}