Skip to content

Commit 361f644

Browse files
authored
ATL-589 fix security vulnerabilities (#123)
* ATL-589 Update vulnerable dependencies and add zod package * ATL-589 Remove workspace from lxrConfig * ATL-589 Add validation schemas and types for JwtClaims, and a parsing method * ATL-589 Remove workspace question from questionaire * ATL-589 Remove references to workspace name from templates * ATL-589 Add type inference to package.json object * ATL-589 Update the startLocalServer method to consume jwtClaims atttributes * ATL-589 Update the upload method to consume the JwtClaims attributes to build the url * ATL-589 Update the CLI definition * ATL-589 Bump version to v1.0.0-beta.28 * ATL-589 Pin dependencies * ARL-589 Update jwt-decode dependency * ATL-589 Update the getJwtClaims method
1 parent 7f222d6 commit 361f644

File tree

11 files changed

+910
-644
lines changed

11 files changed

+910
-644
lines changed

package-lock.json

Lines changed: 814 additions & 586 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@leanix/reporting-cli",
33
"type": "commonjs",
4-
"version": "1.0.0-beta.27",
4+
"version": "1.0.0-beta.28",
55
"description": "Command line interface to develop custom reports for LeanIX EAM",
66
"preferGlobal": true,
77
"author": "LeanIX GmbH <support@leanix.net>",
@@ -28,28 +28,30 @@
2828
"test": "jest",
2929
"test-install": "npm run build && npm -g install .",
3030
"prepare": "npm run build",
31-
"prepublishOnly": "npm run lint && npm test"
31+
"prepublishOnly": "npm run lint && npm test",
32+
"type-check": "npx tsc --noEmit"
3233
},
3334
"dependencies": {
34-
"axios": "1.10.0",
35+
"axios": "1.12.2",
3536
"chalk": "4.1.2",
3637
"commander": "12.1.0",
3738
"cross-spawn": "7.0.6",
3839
"ejs": "3.1.10",
3940
"form-data": "4.0.4",
4041
"https-proxy-agent": "7.0.6",
41-
"inquirer": "7.3.3",
42-
"jwt-decode": "2.2.0",
42+
"inquirer": "8.2.7",
43+
"jwt-decode": "4.0.0",
4344
"lodash": "4.17.21",
4445
"mkdirp": "3.0.1",
4546
"opn": "5.5.0",
4647
"rimraf": "6.0.1",
47-
"tar": "6.2.1"
48+
"tar": "6.2.1",
49+
"zod": "^4.1.9"
4850
},
4951
"devDependencies": {
5052
"@antfu/eslint-config": "4.19.0",
5153
"@types/ejs": "3.1.5",
52-
"@types/inquirer": "7.3.3",
54+
"@types/inquirer": "8.2.7",
5355
"@types/jest": "29.5.13",
5456
"@types/lodash": "4.17.9",
5557
"@types/mkdirp": "2.0.0",

src/app.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ the "host" given in lxr.json.`)
7676
.action(async () => {
7777
const cliConfig = loadCliConfig()
7878
const lxrConfig = loadLxrConfig()
79-
const url = `https://${lxrConfig.host}/services/pathfinder/v1/reports/upload`
79+
const { host: tokenhost, apitoken, proxyURL } = lxrConfig
8080

8181
const builder = new Builder(console)
8282
const bundler = new Bundler()
@@ -87,7 +87,7 @@ the "host" given in lxr.json.`)
8787
try {
8888
await builder.build(cliConfig.distPath, cliConfig.buildCommand)
8989
await bundler.bundle(cliConfig.distPath)
90-
await uploader.upload(url, lxrConfig.apitoken, lxrConfig.host, lxrConfig.proxyURL)
90+
await uploader.upload({ tokenhost, apitoken, proxyURL })
9191
}
9292
catch (error) {
9393
handleError(error)
@@ -97,14 +97,12 @@ the "host" given in lxr.json.`)
9797
program
9898
.command('store-upload <id> <apitoken>')
9999
.description('Uploads the report to the LeanIX Store')
100+
.requiredOption('--tokenhost <tokenhost>', 'Where to resolve the apitoken (default: app.leanix.net)')
100101
.option('--host <host>', 'Which store to use (default: store.leanix.net)')
101-
.option('--tokenhost <tokenhost>', 'Where to resolve the apitoken (default: app.leanix.net)')
102-
.action(async (id: string, apitoken: string, options: { host: string, tokenhost: string }) => {
102+
.action(async (assetVersionId: string, apitoken: string, options: { host: string, tokenhost: string }) => {
103103
const cliConfig = loadCliConfig()
104104

105105
const host = options.host || 'store.leanix.net'
106-
const tokenhost = options.tokenhost || 'app.leanix.net'
107-
const url = `https://${host}/services/torg/v1/assetversions/${id}/payload`
108106

109107
const builder = new Builder(console)
110108
const bundler = new Bundler()
@@ -115,7 +113,7 @@ program
115113
try {
116114
await builder.build(cliConfig.distPath, cliConfig.buildCommand)
117115
await bundler.bundle(cliConfig.distPath)
118-
await uploader.upload(url, apitoken, tokenhost)
116+
await uploader.upload({ apitoken, tokenhost: options.tokenhost, store: { assetVersionId, host } })
119117
}
120118
catch (error) {
121119
handleError(error)

src/dev-starter.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import type { LxrConfig } from './interfaces'
22
import { join } from 'node:path'
33
import chalk from 'chalk'
44
import { spawn } from 'cross-spawn'
5-
import jwtDecode from 'jwt-decode'
6-
import * as _ from 'lodash'
75
import opn from 'opn'
86
import { ApiTokenResolver } from './api-token-resolver'
97
import { loadLxrConfig } from './file.helpers'
8+
import { getJwtClaims } from './helpers'
109

1110
interface DevServerStartResult {
1211
launchUrl: string
@@ -27,24 +26,21 @@ export class DevStarter {
2726
)
2827
}
2928

30-
private async startLocalServer(config: LxrConfig, accessToken?: string): Promise<DevServerStartResult> {
29+
private async startLocalServer(config: LxrConfig, accessToken: string): Promise<DevServerStartResult> {
3130
const port = config.localPort || 8080
3231
const localhostUrl = `https://localhost:${port}`
3332
const urlEncoded = encodeURIComponent(localhostUrl)
34-
const host = `https://${config.host}`
33+
const claims = getJwtClaims(accessToken)
3534

36-
const accessTokenHash = accessToken ? `#access_token=${accessToken}` : ''
37-
const workspace = accessToken ? this.getWorkspaceFromAccesToken(accessToken) : config.workspace
35+
const instanceUrl = claims.instanceUrl
36+
const workspace = claims.principal.permission.workspaceName
3837

39-
if (_.isEmpty(workspace)) {
40-
console.error(chalk.red('Workspace not specified. The local server can\'t be started.'))
41-
return new Promise(null)
42-
}
4338
console.log(chalk.green(`Your workspace is ${workspace}`))
4439

45-
const baseLaunchUrl = `${host}/${workspace}/reports/dev?url=${urlEncoded}`
46-
const launchUrl = baseLaunchUrl + accessTokenHash
47-
console.log(chalk.green(`Starting development server and launching with url: ${baseLaunchUrl}`))
40+
const launchUrl = new URL(`${instanceUrl}/${workspace}/reports/dev?url=${urlEncoded}`)
41+
launchUrl.hash = `access_token=${accessToken}`
42+
43+
console.log(chalk.green(`Starting development server and launching with url: ${launchUrl}`))
4844

4945
const wpMajorVersion = await this.getCurrentWebpackMajorVersion()
5046
const args = ['--port', `${port}`]
@@ -91,7 +87,7 @@ export class DevStarter {
9187
}
9288

9389
if (projectRunning && output.toLowerCase().includes('compiled successfully')) {
94-
resolve({ launchUrl, localhostUrl })
90+
resolve({ launchUrl: launchUrl.toString(), localhostUrl })
9591
}
9692
})
9793
})
@@ -109,19 +105,12 @@ export class DevStarter {
109105
})
110106
}
111107

112-
private getWorkspaceFromAccesToken(accessToken: string) {
113-
const claims = jwtDecode(accessToken)
114-
return claims.principal.permission.workspaceName
115-
}
116-
117108
private async getAccessToken(config: LxrConfig): Promise<string> {
118-
if (!_.isEmpty(config.apitoken)) {
119-
const token = await ApiTokenResolver.getAccessToken(`https://${config.host}`, config.apitoken, config.proxyURL)
120-
return token
121-
}
122-
else {
123-
return Promise.resolve(null)
109+
if (!config.apitoken) {
110+
throw new Error('no api token provided, please include it in the lxr.json file')
124111
}
112+
const token = await ApiTokenResolver.getAccessToken(`https://${config.host}`, config.apitoken, config.proxyURL)
113+
return token
125114
}
126115

127116
private openUrlInBrowser(url: string) {

src/helpers.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,52 @@
11
import { HttpsProxyAgent } from 'https-proxy-agent'
2+
import { InvalidTokenError, jwtDecode } from 'jwt-decode'
3+
import z, { ZodError } from 'zod'
4+
5+
export const jwtClaimsPrincipalSchema = z.object({
6+
id: z.string(),
7+
username: z.string(),
8+
role: z.string(),
9+
status: z.string(),
10+
account: z.object({ id: z.string(), name: z.string() }),
11+
permission: z.object({
12+
id: z.string(),
13+
workspaceId: z.string(),
14+
workspaceName: z.string(),
15+
role: z.string(),
16+
status: z.string()
17+
})
18+
})
19+
20+
export const jwtClaimsSchema = z.object({
21+
sub: z.string(),
22+
principal: jwtClaimsPrincipalSchema,
23+
iss: z.url(),
24+
jti: z.string(),
25+
exp: z.number(),
26+
instanceUrl: z.url(),
27+
region: z.string()
28+
})
29+
30+
export type TJwtClaims = z.infer<typeof jwtClaimsSchema>
31+
32+
export const getJwtClaims = (bearerToken: string): TJwtClaims => {
33+
let claims: TJwtClaims
34+
let claimsUnchecked: unknown
35+
try {
36+
claimsUnchecked = jwtDecode(bearerToken)
37+
claims = jwtClaimsSchema.parse(claimsUnchecked)
38+
}
39+
catch (err) {
40+
if (err instanceof InvalidTokenError) {
41+
console.error('could not decode jwt token', err)
42+
}
43+
if (err instanceof ZodError) {
44+
console.error('unexpected jwt claims schema', err.message)
45+
}
46+
throw err
47+
}
48+
return claims
49+
}
250

351
export const getAxiosProxyConfiguration = (proxyConfig: string): HttpsProxyAgent<string> => {
452
const httpsAgent = new HttpsProxyAgent(proxyConfig)

src/initializer.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,6 @@ export class Initializer {
5959
default: 'app.leanix.net',
6060
message: 'Which host do you want to work with?'
6161
},
62-
{
63-
type: 'input',
64-
name: 'workspace',
65-
message: 'Which is the workspace you want to test your report in?'
66-
},
6762
{
6863
type: 'input',
6964
name: 'apitoken',

src/interfaces.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export interface LxrConfig {
22
host: string
3-
workspace: string
43
apitoken: string
54
localPort?: string
65
proxyURL?: string

src/uploader.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,25 @@ import axios from 'axios'
44
import chalk from 'chalk'
55
import FormData from 'form-data'
66
import { ApiTokenResolver } from './api-token-resolver'
7-
import { getAxiosProxyConfiguration } from './helpers'
7+
import { getAxiosProxyConfiguration, getJwtClaims } from './helpers'
88
import { getProjectDirectoryPath } from './path.helpers'
99

1010
export class Uploader {
11-
public async upload(url: string, apitoken: string, tokenhost: string, proxy?: string): Promise<boolean> {
12-
const accessToken = await ApiTokenResolver.getAccessToken(`https://${tokenhost}`, apitoken, proxy)
13-
return await this.executeUpload(url, accessToken, proxy)
14-
}
11+
public async upload(params: { tokenhost: string, apitoken: string, proxyURL?: string, store?: { assetVersionId: string, host?: string } }) {
12+
const { tokenhost, apitoken, proxyURL, store } = params
13+
const accessToken = await ApiTokenResolver.getAccessToken(`https://${tokenhost}`, apitoken, proxyURL)
14+
15+
const claims = getJwtClaims(accessToken)
16+
let url: string
17+
if (store) {
18+
const { host = 'store.leanix.net', assetVersionId } = store
19+
url = `https://${host}/services/torg/v1/assetversions/${assetVersionId}/payload`
20+
}
21+
else {
22+
url = `${claims.instanceUrl}/services/pathfinder/v1/reports/upload`
23+
}
1524

16-
private async executeUpload(url: string, accessToken: string, proxy?: string) {
17-
console.log(chalk.yellow(chalk.italic(`Uploading to ${url} ${proxy ? `through a proxy` : ``}...`)))
25+
console.log(chalk.yellow(chalk.italic(`Uploading to ${url} ${proxyURL ? `through a proxy` : ``}...`)))
1826

1927
const formData = new FormData()
2028
formData.append('file', fs.createReadStream(getProjectDirectoryPath('bundle.tgz')))
@@ -26,12 +34,12 @@ export class Uploader {
2634
}
2735
}
2836

29-
if (proxy) {
30-
options.httpsAgent = getAxiosProxyConfiguration(proxy)
37+
if (proxyURL) {
38+
options.httpsAgent = getAxiosProxyConfiguration(proxyURL)
3139
}
3240

3341
try {
34-
const response = await axios.post(url, formData, options)
42+
const response = await axios.post(url.toString(), formData, options)
3543
if (response.data.status === 'OK') {
3644
console.log(chalk.green('\u2713 Project successfully uploaded!'))
3745
return true

src/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { join } from 'node:path'
22
import { readJsonFile } from './file.helpers'
33

4-
const packageJson = readJsonFile(join(__dirname, '..', 'package.json'))
4+
const packageJson = readJsonFile(join(__dirname, '..', 'package.json')) as { version: string }
55
export const version = packageJson.version

template/README.md.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ Builds the report and outputs the build result into `dist` folder.
3030

3131
`npm run upload`
3232

33-
Uploads the report to the workspace configured in `lxr.json`.
33+
Uploads the report to the workspace where the API token was issued.
3434
Please see [Uploading to LeanIX workspace](https://github.com/leanix/leanix-reporting-cli#uploading-to-leanix-workspace).

0 commit comments

Comments
 (0)