Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[K9VULN-3872] Add --git-repository option to sbom/sarif upload #1570

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
10 changes: 8 additions & 2 deletions src/commands/sarif/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ The positional arguments are the directories or file paths in which the SARIF re
- `--max-concurrency` (default: `20`): number of concurrent uploads to the API.
- `--dry-run` (default: `false`): runs the command without the final upload step. All other checks are performed.
- `--no-verify` (default: `false`): runs the command without performing report validation on the CLI.
- `--no-ci-tags` (default: `false`): ignore automatic detection of continuous integration environment variables.
- `--git-repository` (default: `current working directory`): reports git environment context from specified repository.

### Environment variables

Expand All @@ -34,9 +36,13 @@ Additionally, you may configure the `sarif` command with environment variables:
- `DD_TAGS`: Set global tags applied to all spans. The format must be `key1:value1,key2:value2`. The upload process merges the tags passed on the command line with the tags in the `--tags` parameter. If a key appears in both `--tags` and `DD_TAGS`, the value in `DD_TAGS` takes precedence.
- `DATADOG_SITE` or `DD_SITE`: choose your Datadog site, for example, datadoghq.com or datadoghq.eu.

### Optional dependencies
### Git context resolution

- [`git`](https://git-scm.com/downloads) is used for extracting repository metadata.
The Git context is resolved in the following order of priority:
1. Current process location
2. CI environment variables (can be disabled with: `--no-ci-tags` option)
3. Explicitly provided Git repository (through `--git-repository` option)
4. Override environment variables (`DD_GIT_*` variables)

### End-to-end testing process

Expand Down
26 changes: 26 additions & 0 deletions src/commands/sarif/__tests__/fixtures/gitconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[core]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did create a duplicate "gitconfig" file in sbom/fixtures and sarif/fixtures as I couldn't settle on where to put it in common directory. And it would keep ensuring that those commands have independent test set.
But if you feel strongly another way, please let me know and I would move it.

repositoryformatversion = 1
filemode = false
bare = false
logallrefupdates = false
ignorecase = true

[init]
defaultBranch = mock-branch

[user]
name = MockUser123
email = [email protected]

[remote "mock-origin"]
url = https://mock-repo.local/fake.git
fetch = +refs/mocks/*:refs/remotes/mock-origin/*

[branch "mock-branch"]
remote = mock-origin
merge = refs/heads/mock-branch
rebase = never # Unusual setting

[hooks]
pre-commit = echo 'Mock pre-commit hook executed'
pre-push = echo 'Mock pre-push hook executed'
98 changes: 92 additions & 6 deletions src/commands/sarif/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import fs from 'fs'
import os from 'os'
import path from 'path'

import {Cli} from 'clipanion/lib/advanced'
import simpleGit from 'simple-git'

import {renderInvalidFile} from '../renderer'
import {UploadSarifReportCommand} from '../upload'
Expand Down Expand Up @@ -166,11 +169,11 @@ describe('upload', () => {
})

describe('execute', () => {
const runCLI = async (paths: string[]) => {
const runCLI = async (args: string[]) => {
const cli = makeCli()
const context = createMockContext() as any
process.env = {DATADOG_API_KEY: 'PLACEHOLDER'}
const code = await cli.run(['sarif', 'upload', '--env', 'ci', '--dry-run', ...paths], context)
const code = await cli.run(['sarif', 'upload', '--env', 'ci', '--dry-run', ...args], context)

return {context, code}
}
Expand Down Expand Up @@ -209,17 +212,72 @@ describe('execute', () => {
basePaths: [`${process.cwd()}/src/commands/sarif/__tests__/fixtures/subfolder`],
concurrency: 20,
env: 'ci',
spanTags: {
'git.repository_url': 'DataDog/datadog-ci',
env: 'ci',
},
})
})

test('absolute path when passing git repository', async () => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitPath-'))
try {
// Configure local git repository
const git = simpleGit(tmpdir)
setupLocalGitConfig(tmpdir)

await git.init()

// eslint-disable-next-line no-null/no-null
await git.commit('Initial commit', [], {'--allow-empty': null})
const repositoryParam = `--git-repository=${tmpdir}`

const {context, code} = await runCLI([
repositoryParam,
process.cwd() + '/src/commands/sarif/__tests__/fixtures/subfolder',
])

const output = context.stdout.toString().split(os.EOL)
expect(code).toBe(0)

checkConsoleOutput(output, {
basePaths: [`${process.cwd()}/src/commands/sarif/__tests__/fixtures/subfolder`],
concurrency: 20,
env: 'ci',
spanTags: {
'git.repository_url': 'mock-repo.local/fake.git',
'git.branch': 'mock-branch',
'git.commit.message': 'Initial commit',
'git.commit.committer.email': '[email protected]',
'git.commit.committer.name': 'MockUser123',
'git.commit.author.email': '[email protected]',
'git.commit.author.name': 'MockUser123',
env: 'ci',
},
})
} finally {
// Removed temporary git file
fs.rmSync(tmpdir, {recursive: true, force: true})
}
})

test('absolute path when passing git repository which does not exist', async () => {
const nonExistingGitRepository = '/you/cannot/find/me'
const repositoryParam = `--git-repository=${nonExistingGitRepository}`

// Pass a git repository which does not exist, command should fail
const {code} = await runCLI([repositoryParam, process.cwd() + '/src/commands/sarif/__tests__/fixtures/subfolder'])
expect(code).toBe(1)
})

test('single file', async () => {
const {context, code} = await runCLI([process.cwd() + '/src/commands/sarif/__tests__/fixtures/valid-results.sarif'])
const output = context.stdout.toString().split(os.EOL)
const path = `${process.cwd()}/src/commands/sarif/__tests__/fixtures/valid-results.sarif`
const location = `${process.cwd()}/src/commands/sarif/__tests__/fixtures/valid-results.sarif`
expect(code).toBe(0)
expect(output[0]).toContain('DRY-RUN MODE ENABLED. WILL NOT UPLOAD SARIF REPORT')
expect(output[1]).toContain('Starting upload with concurrency 20.')
expect(output[2]).toContain(`Will upload SARIF report file ${path}`)
expect(output[2]).toContain(`Will upload SARIF report file ${location}`)
expect(output[3]).toContain('Only one upload per commit, env and tool')
expect(output[4]).toContain(`Preparing upload for`)
expect(output[4]).toContain(`env:ci`)
Expand All @@ -228,9 +286,9 @@ describe('execute', () => {
test('not found file', async () => {
const {context, code} = await runCLI([process.cwd() + '/src/commands/sarif/__tests__/fixtures/not-found.sarif'])
const output = context.stdout.toString().split(os.EOL)
const path = `${process.cwd()}/src/commands/sarif/__tests__/fixtures/not-found.sarif`
const location = `${process.cwd()}/src/commands/sarif/__tests__/fixtures/not-found.sarif`
expect(code).toBe(1)
expect(output[0]).toContain(`Cannot find valid SARIF report files to upload in ${path}`)
expect(output[0]).toContain(`Cannot find valid SARIF report files to upload in ${location}`)
expect(output[1]).toContain('Check the files exist and are valid.')
})
})
Expand All @@ -239,6 +297,7 @@ interface ExpectedOutput {
basePaths: string[]
concurrency: number
env: string
spanTags?: Record<string, string>
}

const checkConsoleOutput = (output: string[], expected: ExpectedOutput) => {
Expand All @@ -248,4 +307,31 @@ const checkConsoleOutput = (output: string[], expected: ExpectedOutput) => {
expect(output[3]).toContain('Only one upload per commit, env and tool')
expect(output[4]).toContain(`Preparing upload for`)
expect(output[4]).toContain(`env:${expected.env}`)

if (expected.spanTags) {
const regex = /with tags (\{.*\})/
const match = output[5].match(regex)
expect(match).not.toBeNull()

const spanTags = JSON.parse(match![1])
Object.keys(expected.spanTags).forEach((k) => {
expect(spanTags[k]).not.toBeNull()
expect(spanTags[k]).toContain(expected.spanTags![k])
})
}
}

const getFixtures = (file: string) => {
return path.join('./src/commands/sarif/__tests__/fixtures', file)
}

const setupLocalGitConfig = (dir: string) => {
const gitDir = path.join(dir, '.git')
if (!fs.existsSync(gitDir)) {
fs.mkdirSync(gitDir, {recursive: true})
}

const configFixture = fs.readFileSync(getFixtures('gitconfig'), 'utf8')
const configPath = path.join(gitDir, '/config')
fs.writeFileSync(configPath, configFixture)
}
4 changes: 3 additions & 1 deletion src/commands/sarif/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ export const renderSuccessfulCommand = (fileCount: number, duration: number) =>
return fullStr
}

export const renderDryRunUpload = (payload: Payload): string => `[DRYRUN] ${renderUpload(payload)}`
export const renderDryRunUpload = (payload: Payload): string => `[DRYRUN] ${renderUploadWithSpan(payload)}`

export const renderUpload = (payload: Payload): string => `Uploading SARIF report in ${payload.reportPath}\n`
export const renderUploadWithSpan = (payload: Payload): string =>
`Uploading SARIF report to ${payload.reportPath} with tags ${JSON.stringify(payload.spanTags)}\n`

export const renderCommandInfo = (
basePaths: string[],
Expand Down
5 changes: 3 additions & 2 deletions src/commands/sarif/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class UploadSarifReportCommand extends Command {
private maxConcurrency = Option.String('--max-concurrency', '20', {validator: validation.isInteger()})
private serviceFromCli = Option.String('--service')
private tags = Option.Array('--tags')
private gitPath = Option.String('--git-repository')
private noVerify = Option.Boolean('--no-verify', false)
private noCiTags = Option.Boolean('--no-ci-tags', false)

Expand Down Expand Up @@ -110,7 +111,7 @@ export class UploadSarifReportCommand extends Command {
// Always using the posix version to avoid \ on Windows.
this.basePaths = this.basePaths.map((basePath) => path.posix.normalize(basePath))

const spanTags = await getSpanTags(this.config, this.tags, !this.noCiTags)
const spanTags = await getSpanTags(this.config, this.tags, !this.noCiTags, this.gitPath)

// Gather any missing mandatory git fields to display to the user
const missingGitFields = getMissingRequiredGitTags(spanTags)
Expand All @@ -133,7 +134,7 @@ export class UploadSarifReportCommand extends Command {
this.context.stdout.write(
renderCommandInfo(this.basePaths, env, sha, this.maxConcurrency, this.dryRun, this.noVerify)
)
const upload = (p: Payload) => this.uploadSarifReport(api, p)
const upload = (payload: Payload) => this.uploadSarifReport(api, payload)

const initialTime = new Date().getTime()

Expand Down
16 changes: 14 additions & 2 deletions src/commands/sbom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ This command lets you upload SBOM files to the Datadog intake endpoint.

- CycloneDX 1.4
- CycloneDX 1.5
- CycloneDX 1.6
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lucky documentation change, we do support 1.6 too

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


## Usage

```bash
datadog-ci sbom upload <path/to/sbom.json>
datadog-ci sbom upload [--env] [--no-ci-tags] [--git-repository] [--debug] <path/to/sbom.json>
```

### Optional arguments

- `--env` is a string that represents the environment in which you want your tests to appear.
- `--env` (default: `ci`): represents the environment in which you want your sbom to appear.
- `--no-ci-tags` (default: `false`): ignore automatic detection of continuous integration environment variables.
- `--git-repository` (default: `current working directory`): reports git environment context from specified repository.
- `--debug` (default: `false`): output debug logs.

### Environment variables

Expand All @@ -26,6 +30,14 @@ The following environment variables must be defined:
- `DD_APP_KEY`: the App key to use
- `DD_API_KEY`: the API key to use

### Git context resolution

The Git context is resolved in the following order of priority:
1. Current process location
2. CI environment variables (can be disabled with: `--no-ci-tags` option)
3. Explicitly provided Git repository (through --git-repository option)
4. Override environment variables (`DD_GIT_*` variables)

## Development

When developing software, you can try with the following command:
Expand Down
26 changes: 26 additions & 0 deletions src/commands/sbom/__tests__/fixtures/gitconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[core]
repositoryformatversion = 1
filemode = false
bare = false
logallrefupdates = false
ignorecase = true

[init]
defaultBranch = mock-branch

[user]
name = MockUser123
email = [email protected]

[remote "mock-origin"]
url = https://mock-repo.local/fake.git
fetch = +refs/mocks/*:refs/remotes/mock-origin/*

[branch "mock-branch"]
remote = mock-origin
merge = refs/heads/mock-branch
rebase = never # Unusual setting

[hooks]
pre-commit = echo 'Mock pre-commit hook executed'
pre-push = echo 'Mock pre-push hook executed'
80 changes: 79 additions & 1 deletion src/commands/sbom/__tests__/payload.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import fs from 'fs'
import os from 'os'
import path from 'path'

import simpleGit from 'simple-git'

import {DatadogCiConfig} from '../../../helpers/config'
import {getSpanTags} from '../../../helpers/tags'
import {getSpanTags, getMissingRequiredGitTags} from '../../../helpers/tags'

import {generatePayload} from '../payload'
import {DependencyLanguage, Location} from '../types'
Expand Down Expand Up @@ -343,4 +347,78 @@ describe('generation of payload', () => {
expect(dependencies[1].name).toEqual('jinja2')
expect(dependencies[1].version).toEqual('3.1.5')
})

test('should correctly work with a CycloneDX 1.4 file and passing git repository', async () => {
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitPath-'))
try {
// Configure local git repository
const git = simpleGit(tmpdir)
setupLocalGitConfig(tmpdir)
await git.init()
// eslint-disable-next-line no-null/no-null
await git.commit('Initial commit', [], {'--allow-empty': null})

const sbomFile = './src/commands/sbom/__tests__/fixtures/sbom.1.4.ok.json'
const sbomContent = JSON.parse(fs.readFileSync(sbomFile).toString('utf8'))
const config: DatadogCiConfig = {
apiKey: undefined,
env: undefined,
envVarTags: undefined,
}

// Pass git directory to load git context
const tags = await getSpanTags(config, [], true, tmpdir)
expect(getMissingRequiredGitTags(tags)).toHaveLength(0)

const payload = generatePayload(sbomContent, tags, 'service', 'env')
expect(payload).not.toBeNull()
expect(payload?.id).toStrictEqual(expect.any(String))

// Local git repository should be reported
expect(payload?.commit.sha).toStrictEqual(expect.any(String))
expect(payload?.commit.author_name).toStrictEqual('MockUser123')
expect(payload?.commit.author_email).toStrictEqual('[email protected]')
expect(payload?.commit.committer_name).toStrictEqual('MockUser123')
expect(payload?.commit.committer_email).toStrictEqual('[email protected]')
expect(payload?.commit.branch).toStrictEqual('mock-branch')
expect(payload?.repository.url).toContain('https://mock-repo.local/fake.git')
expect(payload?.dependencies.length).toBe(62)
expect(payload?.dependencies[0].name).toBe('stack-cors')
expect(payload?.dependencies[0].version).toBe('1.3.0')
expect(payload?.dependencies[0].licenses.length).toBe(0)
expect(payload?.dependencies[0].language).toBe(DependencyLanguage.PHP)
} finally {
// Removed temporary git file
fs.rmSync(tmpdir, {recursive: true, force: true})
}
})

test('should fail to read git information', async () => {
const nonExistingGitRepository = '/you/cannot/find/me'
const config: DatadogCiConfig = {
apiKey: undefined,
env: undefined,
envVarTags: undefined,
}

// Pass non existing git directory to load git context
// It is missing all git tags.
const tags = await getSpanTags(config, [], true, nonExistingGitRepository)
expect(getMissingRequiredGitTags(tags).length).toBeGreaterThanOrEqual(1)
})
})

const getFixtures = (file: string) => {
return path.join('./src/commands/sbom/__tests__/fixtures', file)
}

const setupLocalGitConfig = (dir: string) => {
const gitDir = path.join(dir, '.git')
if (!fs.existsSync(gitDir)) {
fs.mkdirSync(gitDir, {recursive: true})
}

const configFixture = fs.readFileSync(getFixtures('gitconfig'), 'utf8')
const configPath = path.join(gitDir, '/config')
fs.writeFileSync(configPath, configFixture)
}
Loading
Loading