@eurekadevsecops/radar 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -2,8 +2,7 @@
2
2
 
3
3
  // require('dotenv').config()
4
4
  const path = require('node:path')
5
- const plugins = { scanners: require(path.join(__dirname, 'src', 'plugins', 'scanners')) }
6
- const cli = require(path.join(__dirname, 'src')).build({ plugins })
5
+ const cli = require(path.join(__dirname, 'src')).build()
7
6
 
8
7
  // Check for updates (not in browsers).
9
8
  cli.checkForUpdates()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eurekadevsecops/radar",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Radar is an open-source orchestrator of security scanners.",
5
5
  "homepage": "https://www.eurekadevsecops.com/radar",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  "@persistr/clif": "^1.11.0",
31
31
  "@persistr/clif-plugin-settings": "^2.3.1",
32
32
  "humanize-duration": "^3.33.0",
33
+ "luxon": "^3.7.1",
33
34
  "smol-toml": "^1.4.1",
34
35
  "tiny-spinner": "^2.0.5"
35
36
  },
@@ -4,5 +4,5 @@
4
4
  # $3 - Path to the output folder where scan results should be stored
5
5
 
6
6
  set -e
7
- docker run --rm -v $1:/app -v $2:/input -v $3:/output ghcr.io/owasp-dep-scan/dep-scan depscan --src /app --reports-dir /output/depscan --report-name depscan.sarif --report-template /input/sarif.j2 2>&1
7
+ docker run --rm -v $1:/app -v $2:/input -v $3:/output ghcr.io/eurekadevsecops/radar-depscan 2>&1
8
8
  cp $3/depscan/depscan.sarif $3/depscan.sarif
@@ -4,4 +4,4 @@
4
4
  # $3 - Path to the output folder where scan results should be stored
5
5
 
6
6
  set -e
7
- docker run --rm -t -v $1:/app -v $2:/input -v $3:/home/output radar/opengrep:latest /app 2>&1
7
+ docker run --rm -v $1:/app -v $2:/input -v $3:/output ghcr.io/eurekadevsecops/radar-opengrep 2>&1
@@ -46,11 +46,12 @@ module.exports = {
46
46
  some may belong to multiple categories. You could run all available SAST
47
47
  scanners, for example, by passing in SAST as the value for the CATEGORIES
48
48
  option. Values are case-insensitive. Multiple values should be comma-separated.
49
- Defaults to "SAST".
49
+ Defaults to "SAST", unless you use the SCANNERS option in which case the
50
+ default value for CATEGORIES is ignored. To run all scanners across all
51
+ categories, use the value "all" for CATEGORIES.
50
52
 
51
- You can specify both SCANNERS and CATEGORIES at the same time. The list of
52
- available scanners is filtered by the given SCANNERS and CATEGORIES on the
53
- command line, and any matching scanners are then used for the scan.
53
+ You can specify both SCANNERS and CATEGORIES at the same time. This will run
54
+ all scanners provided in both options.
54
55
 
55
56
  By default, findings are displayed as high, moderate, and low. This is the
56
57
  'security' severity format. Findings can also be displayed as errors, warnings,
@@ -81,16 +82,22 @@ module.exports = {
81
82
  '$ radar scan -e warning,note ' + '(treat warnings and notes as errors)'.grey
82
83
  ],
83
84
  run: async (toolbox, args) => {
84
- const { log, scanners: availableScanners } = toolbox
85
+ const { log, scanners: availableScanners, telemetry } = toolbox
85
86
 
86
87
  // Set defaults.
87
88
  args.TARGET = path.normalize(args.TARGET ?? process.cwd())
88
89
  args.FORMAT = args.FORMAT ?? 'security'
89
90
  if (args.FORMAT !== 'sarif' && args.FORMAT !== 'security') throw new Error('FORMAT must be one of \'sarif\' or \'security\'')
90
- args.CATEGORIES = args.CATEGORIES ?? 'sast'
91
+ if (args.CATEGORIES && !['all', 'sca', 'sast', 'dast'].includes(args.CATEGORIES.toLowerCase())) throw new Error(`CATEGORIES must be one of 'all', 'SCA', 'SAST', or 'DAST'`)
92
+ if (!args.SCANNERS && !args.CATEGORIES) args.CATEGORIES = 'sast'
93
+ if (!args.SCANNERS && (args.CATEGORIES === 'all')) args.CATEGORIES = ''
94
+ if (args.SCANNERS) {
95
+ const unknownScanners = args.SCANNERS.split(',').filter(name => !availableScanners.find(scanner => scanner.name === name))
96
+ if (unknownScanners.length > 1) throw new Error(`Unknown scanners: ${unknownScanners.join(', ')}`)
97
+ else if (unknownScanners.length === 1) throw new Error(`Unknown scanner: ${unknownScanners[0]}`)
98
+ }
91
99
 
92
100
  // Set scan parameters.
93
- // const target = args.TARGET ? path.resolve(args.TARGET) : "$PWD" // target to scan
94
101
  const target = path.resolve(args.TARGET) // target to scan
95
102
  const assets = path.join(__dirname, '..', '..', 'scanners') // scanner assets
96
103
  const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'radar-')) // output directory
@@ -114,8 +121,23 @@ module.exports = {
114
121
  return false
115
122
  })
116
123
 
124
+ // At least one scanner must be selected in order to have a successful scan.
125
+ if (scanners.length === 0) throw new Error('No available scanners selected.')
126
+
127
+ // Send telemetry: scan started.
128
+ let scanID = undefined
129
+ const isTelemetryEnabled = telemetry.enabled()
130
+ if (isTelemetryEnabled) {
131
+ // TODO: Should pass scanID to the server; not read it from the server.
132
+ const response = await telemetry.send(`scans/started`, {}, { scanners: scanners.map((scanner) => scanner.name) })
133
+ const data = await response.json()
134
+ scanID = data.scan_id
135
+ }
136
+
117
137
  // Run scanners.
118
138
  let isScanCompleted = true
139
+ let runLog = ''
140
+ log(`Running ${scanners.length} of ${availableScanners.length} scanners:`)
119
141
  for (const scanner of scanners) {
120
142
  let label = scanner.name
121
143
  const spinner = new Spinner()
@@ -137,7 +159,9 @@ module.exports = {
137
159
  cmd = cmd.replaceAll('${output}', outdir)
138
160
  /* eslint-enable no-template-curly-in-string */
139
161
 
140
- /* const { stdout } = */ await exec(cmd)
162
+ const { stdout } = await exec(cmd)
163
+ runLog += stdout
164
+
141
165
  if (!args.QUIET) spinner.success(label)
142
166
  } catch (error) {
143
167
  isScanCompleted = false
@@ -202,11 +226,27 @@ module.exports = {
202
226
  fs.writeFileSync(outfile, JSON.stringify(sarif))
203
227
  }
204
228
 
205
- // TODO: Upload SARIF to user's Eureka account.
206
-
207
229
  // Count findings by severity level.
208
230
  const summary = await sariftools.summarize(sarif, target)
209
231
 
232
+ // Send telemetry.
233
+ if (isTelemetryEnabled && scanID) {
234
+ // Scan completed.
235
+ telemetry.send(`scans/:scanID/completed`, { scanID }, {
236
+ findings: {
237
+ total: summary.errors.length + summary.warnings.length + summary.notes.length,
238
+ critical: 0,
239
+ high: summary.errors.length,
240
+ med: summary.warnings.length,
241
+ low: summary.notes.length
242
+ }
243
+ })
244
+
245
+ // Send sensitive telemetry: scan log and scan findings.
246
+ telemetry.sendSensitive(`scans/:scanID/log`, { scanID }, runLog)
247
+ telemetry.sendSensitive(`scans/:scanID/findings`, { scanID }, { findings: sarif })
248
+ }
249
+
210
250
  // Display summarized findings.
211
251
  if (!args.QUIET) {
212
252
  log()
@@ -227,6 +267,7 @@ module.exports = {
227
267
  } else {
228
268
  exitCode = 0x10
229
269
  if (!args.QUIET) log('Scan NOT completed!')
270
+ if (telemetry.enabled()) telemetry.send(`scans/:scanID/failed`, { scanID })
230
271
  }
231
272
 
232
273
  // Clean up.
package/src/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  const { build } = require('@persistr/clif')
2
2
  const pkg = require('../package.json')
3
3
  const commands = require('./commands')
4
+ const path = require('node:path')
4
5
 
5
6
  // Plugins.
6
7
  const plugins = {
7
- settings: require('@persistr/clif-plugin-settings')
8
+ settings: require('@persistr/clif-plugin-settings'),
9
+ scanners: require(path.join(__dirname, 'plugins', 'scanners')),
10
+ telemetry: require(path.join(__dirname, 'plugins', 'telemetry'))
8
11
  }
9
12
 
10
13
  module.exports = {
@@ -0,0 +1,6 @@
1
+ const telemetry = require('../telemetry')
2
+ module.exports = {
3
+ toolbox: {
4
+ telemetry
5
+ }
6
+ }
@@ -0,0 +1,52 @@
1
+ # TELEMETRY
2
+
3
+ ## How to use this module to send telemetry
4
+
5
+ ### Step 1: Import the module:
6
+ ```js
7
+ const telemetry = require('./telemetry')
8
+ ```
9
+
10
+ ### Step 2: Call telemetry.send to send telemetry. Choose one of several telemetry events available.
11
+
12
+ All available telemetry events:
13
+ ```js
14
+ telemetry.send(`scans/:scanID/started`, { scanID }, { scanners })
15
+ telemetry.send(`scans/:scanID/completed`, { scanID }, { status, findings, log })
16
+ telemetry.sendSensitive(`scans/:scanID/log`, { scanID }, log)
17
+ telemetry.sendSensitive(`scans/:scanID/findings`, { scanID }, sarif)
18
+ ```
19
+
20
+ To send a telemetry event indicating that a scan has started:
21
+ ```js
22
+ telemetry.send(`scans/:scanID/started`, { scanID }, { scanners })
23
+ ```
24
+
25
+ To send a telemetry event indicating that a scan has completed:
26
+ ```js
27
+ telemetry.send(`scans/:scanID/completed`, { scanID }, { status, findings, log })
28
+ ```
29
+
30
+ To send a telemetry event with the scan console log:
31
+ ```js
32
+ telemetry.sendSensitive(`scans/:scanID/log`, { scanID }, log)
33
+ ```
34
+
35
+ To send a telemetry event with the scan vulnerability findings:
36
+ ```js
37
+ telemetry.sendSensitive(`scans/:scanID/findings`, { scanID }, sarif)
38
+ ```
39
+
40
+ ### Step 3: (optional) You can await on telemetry.send to read the fetch response.
41
+
42
+ ```js
43
+ const response = await telemetry.send(`scans/:scanID/started`, { scanID }, { scanners })
44
+ console.log(response.statusCode)
45
+ console.log(await response.json())
46
+ ```
47
+
48
+ ## Sensitive data in telemtry events
49
+
50
+ NOTE: Telemetry events that contain vulnerability data are sent to the Vulnerability
51
+ Data Backend (VDBE) which can be hosted by customers directly on their own infrastructure.
52
+ These telemetry events must be sent using the `sendSensitive` function.
@@ -0,0 +1,73 @@
1
+ const package = require('../../package.json')
2
+ const { DateTime } = require("luxon")
3
+
4
+ const EWA_URL = process.env.EWA_URL ?? 'https://app.eurekadevsecops.com'
5
+ const VDBE_URL = process.env.VDBE_URL ?? 'https://vulns.eurekadevsecops.com'
6
+
7
+ const USER_AGENT = `Radar/${package.version} (${package.pkgname}@${package.version}; ${process?.platform}-${process?.arch}; ${process?.release?.name}-${process?.version})`
8
+
9
+ const enabled = () => {
10
+ if (process.env.EUREKA_AGENT_TOKEN) return true
11
+ return false
12
+ }
13
+
14
+ const send = async (path, params, body, token) => {
15
+ return fetch(toURL(path, params), {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Authorization': `Bearer ${token ?? process.env.EUREKA_AGENT_TOKEN}`,
19
+ 'Content-Type': toContentType(path),
20
+ 'User-Agent': USER_AGENT,
21
+ 'Accept': 'application/json'
22
+ },
23
+ body: toBody(path, body)
24
+ })
25
+ }
26
+
27
+ const sendSensitive = async (path, params, body) => {
28
+ return send(path, params, body, await token())
29
+ }
30
+
31
+ const token = async () => {
32
+ const response = await fetch(`${EWA_URL}/vdbe/token`, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Authorization': `Bearer ${process.env.EUREKA_AGENT_TOKEN}`,
36
+ 'Content-Type': 'application/json',
37
+ 'User-Agent': USER_AGENT,
38
+ 'Accept': 'application/json'
39
+ }
40
+ })
41
+ if (!response.ok) throw new Error(`Internal Error: Failed to get VDBE auth token from EWA: ${response.statusText}: ${await response.text()}`)
42
+ const data = await response.json()
43
+ return data.token
44
+ }
45
+
46
+ const toURL = (path, params) => {
47
+ if (path === `scans/started`) return `${EWA_URL}/scans/started`
48
+ if (path === `scans/:scanID/completed`) return `${EWA_URL}/scans/${params.scanID}/completed`
49
+ if (path === `scans/:scanID/failed`) return `${EWA_URL}/scans/${params.scanID}/completed`
50
+ if (path === `scans/:scanID/log`) return `${VDBE_URL}/scans/${params.scanID}/log`
51
+ if (path === `scans/:scanID/findings`) return `${VDBE_URL}/scans/${params.scanID}/findings`
52
+ throw new Error(`Internal Error: Unknown telemetry event: ${path}`)
53
+ }
54
+
55
+ const toContentType = (path) => {
56
+ if (path === `scans/:scanID/log`) return 'text/plain'
57
+ return 'application/json'
58
+ }
59
+
60
+ const toBody = (path, body) => {
61
+ if (path === `scans/:scanID/log`) return body
62
+ if (path === `scans/started`) body = { ...body, timestamp: DateTime.now().toISO(), profile_id: process.env.EUREKA_PROFILE }
63
+ if (path === `scans/:scanID/completed`) body = { ...body, timestamp: DateTime.now().toISO(), status: 'success', log: { sizeBytes: 0, warnings: 0, errors: 0, link: 'none' }, params: { id: '' }}
64
+ if (path === `scans/:scanID/failed`) body = { ...body, timestamp: DateTime.now().toISO(), status: 'failure', findings: { total: 0, critical: 0, high: 0, med: 0, low: 0 }, log: { sizeBytes: 0, warnings: 0, errors: 0, link: 'none' }, params: { id: '' }}
65
+ if (path === `scans/:scanID/findings`) body = { ...body, profileId: process.env.EUREKA_PROFILE }
66
+ return JSON.stringify(body)
67
+ }
68
+
69
+ module.exports = {
70
+ enabled,
71
+ send,
72
+ sendSensitive
73
+ }
@@ -1,6 +1,6 @@
1
1
  const levels = require('./levels')
2
2
  module.exports = async (summary, format, log) => {
3
- for (const finding of summary.notes) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.note}`.bold + `${levels[format].single.suffix}:` + ` ${finding.message}\n`)
4
- for (const finding of summary.warnings) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.warning}`.bold.yellow + `${levels[format].single.suffix}:` + ` ${finding.message}\n`)
5
- for (const finding of summary.errors) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.error}`.bold.red + `${levels[format].single.suffix}:` + ` ${finding.message}\n`)
3
+ for (const finding of summary.notes) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.note}`.bold + `${levels[format].single.suffix}:` + ` ${finding.tool}:` + ` ${finding.message}\n`)
4
+ for (const finding of summary.warnings) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.warning}`.bold.yellow + `${levels[format].single.suffix}:` + ` ${finding.tool}:` + ` ${finding.message}\n`)
5
+ for (const finding of summary.errors) log(`${finding.artifact.name}:${finding.artifact.line}: ` + `${levels[format].single.error}`.bold.red + `${levels[format].single.suffix}:` + ` ${finding.tool}:` + ` ${finding.message}\n`)
6
6
  }
@@ -12,6 +12,7 @@ module.exports = {
12
12
  */
13
13
 
14
14
  const fs = require('node:fs')
15
+ const path = require('node:path')
15
16
  module.exports = async (files, outfile) => {
16
17
  const sarif = {
17
18
  version: '2.1.0',
@@ -25,14 +26,16 @@ module.exports = async (files, outfile) => {
25
26
  for (const run of scan.runs) {
26
27
  const tool = {
27
28
  driver: {
28
- name: run.tool.driver.name,
29
+ name: path.parse(file).name,
29
30
  semanticVersion: run.tool.driver.semanticVersion,
30
31
  informationUri: run.tool.driver.informationUri,
31
- properties: run.tool.driver.properties,
32
+ properties: run.tool.driver.properties ?? {},
32
33
  rules: []
33
34
  }
34
35
  }
35
36
 
37
+ tool.driver.properties.officialName = run.tool.driver.name
38
+
36
39
  const rules = new Map()
37
40
  for (const result of run.results) {
38
41
  rules.set(result.ruleId, true)
@@ -48,5 +51,20 @@ module.exports = async (files, outfile) => {
48
51
  }
49
52
  }
50
53
 
54
+ // Clean up the SARIF object, to conform to the SARIF schema.
55
+ for (const run of sarif.runs) {
56
+ for (const result of run.results) {
57
+ const partialFingerprints = result.partialFingerprints
58
+ if (partialFingerprints) {
59
+ if (!partialFingerprints.commitSha) delete partialFingerprints.commitSha
60
+ if (!partialFingerprints.email) delete partialFingerprints.email
61
+ if (!partialFingerprints.author) delete partialFingerprints.author
62
+ if (!partialFingerprints.date) delete partialFingerprints.date
63
+ if (!partialFingerprints.commitMessage) delete partialFingerprints.commitMessage
64
+ if (Object.keys(partialFingerprints).length === 0) delete result.partialFingerprints
65
+ }
66
+ }
67
+ }
68
+
51
69
  fs.writeFileSync(outfile, JSON.stringify(sarif))
52
70
  }
@@ -1,90 +0,0 @@
1
- {
2
- "version": "2.1.0",
3
- "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
4
- "runs": [
5
- {
6
- "tool": {
7
- "driver": {
8
- "name": "{{ metadata.tools.components[1].name }}",
9
- "semanticVersion": "{{ metadata.tools.components[1].version }}",
10
- "informationUri": "https://github.com/owasp-dep-scan/dep-scan",
11
- "properties": {
12
- "protocol_version": "v1.0.0",
13
- "scanner_name": "{{ metadata.tools.components[1].name }}",
14
- "scanner_version": "{{ metadata.tools.components[1].version }}",
15
- "db": "https://github.com/AppThreat/vulnerability-db",
16
- "scan_mode": "source"
17
- },
18
- "rules": [ {% for vuln in vulnerabilities %}{% set package = vuln['bom-ref'].split(':')[1] %}
19
- {
20
- "id": "{{ vuln['bom-ref'] }}",
21
- "shortDescription": {
22
- "text": "Vulnerable pkg: {{ package }}\nCVE: {{ vuln.id }}\nFix: {{ vuln.recommendation }}\n\n{% for prop in vuln.properties %}{{ prop.name }}: {{ prop.value }}\n{% endfor %}"
23
- },
24
- "fullDescription": {
25
- "text": {{ vuln.description | tojson }}
26
- },
27
- "help": {
28
- "text": "{{ vuln.recommendation }}"
29
- },
30
- "helpUri": "{% if vuln.source and vuln.source.url %}{{ vuln.source.url }}{% elif vuln.id and 'NPM-' in vuln.id %}https://osv.dev/vulnerability/{{ vuln.id.split('/')[0] }}{% else %}https://unknownhelpuri.com{% endif %}",
31
- "properties": {
32
- "tags": [
33
- {% for prop in vuln.properties %}{% if 'Used' in prop.value -%}
34
- "{{ 'Used' }}",
35
- {% endif -%}{% if 'Reachable' in prop.value -%}
36
- "{{ 'Reachable' }}",
37
- {% endif -%}{% if 'Confirmed' in prop.value -%}
38
- "{{ 'Confirmed' }}",
39
- {% endif -%}{% if 'Exploits' in prop.value -%}
40
- "{{ 'Exploits' }}",
41
- {% endif -%}{% if 'PoC' in prop.value -%}
42
- "{{ 'PoC' }}",
43
- {% endif -%}{% if 'true' in prop.value and 'prioritized' in prop.name -%}
44
- "{{ 'Prioritized' }}",
45
- {% endif -%}{% endfor %}{% if 'MAL-' in vuln.id -%}
46
- "{{ 'Malware' }}",
47
- {% endif -%}"{{ vuln['id'] }}"
48
- ]
49
- }
50
- }{% if not loop.last %},{% endif %}
51
- {% endfor %}
52
- ]
53
- }
54
- },
55
- "results": [ {% for vuln in vulnerabilities %}{% set package = vuln['bom-ref'].split(':')[1] %}
56
- {
57
- "ruleId": "{{ vuln['bom-ref'] }}",
58
- "level": {% if vuln.ratings[0].severity in ['critical','high'] -%}
59
- "{{ 'error' }}",
60
- {% endif -%}{% if vuln.ratings[0].severity in ['medium'] -%}
61
- "{{ 'warning' }}",
62
- {% endif -%}{% if vuln.ratings[0].severity in ['low'] -%}
63
- "{{ 'note' }}",
64
- {% endif -%}
65
- "message": {
66
- "text": "Vulnerability {{ vuln.id }} in pkg {{ package }}"
67
- },
68
- "locations": [
69
- {
70
- "physicalLocation": {
71
- "artifactLocation": {
72
- "uri": "lockfile",
73
- "uriBaseId": "%SRCROOT%"
74
- },
75
- "region": {
76
- "startLine": 1
77
- }
78
- },
79
- "message": {
80
- "text": "Vulnerability {{ vuln.id }} in pkg {{ package }}"
81
- }
82
- }
83
- ]
84
- }
85
- {% if not loop.last %},{% endif %}
86
- {% endfor %}
87
- ]
88
- }
89
- ]
90
- }