Skip to content

Commit f91a174

Browse files
authored
Implement --json and --markdown for audit-log (#349)
1 parent 7e8c86e commit f91a174

File tree

4 files changed

+226
-52
lines changed

4 files changed

+226
-52
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@
6060
"test": "run-s check test:*",
6161
"test:prepare": "cross-env VITEST=1 npm run build",
6262
"test:unit": "vitest --run",
63-
"test:unit:update": "vitest --update",
63+
"test:unit:update": "vitest --run --update",
6464
"test:unit:coverage": "vitest run --coverage",
6565
"test-ci": "run-s test:*",
66-
"testu": "cross-env SOCKET_CLI_NO_API_TOKEN=1 run-s test:prepare test:unit:update",
66+
"testu": "cross-env SOCKET_CLI_NO_API_TOKEN=1 run-s test:prepare; npm run test:unit:update --",
6767
"update": "run-p --aggregate-output update:**",
6868
"update:deps": "npx --yes npm-check-updates"
6969
},

src/commands/audit-log/cmd-audit-log.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import { logger } from '@socketsecurity/registry/lib/logger'
55
import { getAuditLog } from './get-audit-log'
66
import constants from '../../constants'
77
import { commonFlags, outputFlags } from '../../flags'
8-
import { AuthError } from '../../utils/errors'
98
import { meowOrExit } from '../../utils/meow-with-subcommands'
109
import { getFlagListOutput } from '../../utils/output-formatting'
11-
import { getDefaultToken } from '../../utils/sdk'
1210

1311
import type { CliCommandConfig } from '../../utils/meow-with-subcommands'
1412

@@ -70,16 +68,22 @@ async function run(
7068
parentName
7169
})
7270

73-
const type = String(cli.flags['type'] || '')
71+
const { json, markdown, page, perPage, type } = cli.flags
72+
73+
const logType = String(type || '')
7474
const [orgSlug = ''] = cli.input
7575

7676
if (!orgSlug) {
7777
// Use exit status of 2 to indicate incorrect usage, generally invalid
7878
// options or missing arguments.
7979
// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html
8080
process.exitCode = 2
81-
logger.error(`${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n
82-
- Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')}\n`)
81+
logger.error(
82+
`
83+
${colors.bgRed(colors.white('Input error'))}: Please provide the required fields:\n
84+
- Org name as the first argument ${!orgSlug ? colors.red('(missing!)') : colors.green('(ok)')}\n
85+
`.trim()
86+
)
8387
return
8488
}
8589

@@ -88,20 +92,11 @@ async function run(
8892
return
8993
}
9094

91-
const apiToken = getDefaultToken()
92-
if (!apiToken) {
93-
throw new AuthError(
94-
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
95-
)
96-
}
97-
9895
await getAuditLog({
99-
apiToken,
10096
orgSlug,
101-
outputJson: Boolean(cli.flags['json']),
102-
outputMarkdown: Boolean(cli.flags['markdown']),
103-
page: Number(cli.flags['page'] || 0),
104-
perPage: Number(cli.flags['perPage'] || 0),
105-
type: type.charAt(0).toUpperCase() + type.slice(1)
97+
outputKind: json ? 'json' : markdown ? 'markdown' : 'print',
98+
page: Number(page || 0),
99+
perPage: Number(perPage || 0),
100+
logType: logType.charAt(0).toUpperCase() + logType.slice(1)
106101
})
107102
}
Lines changed: 210 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { logger } from '@socketsecurity/registry/lib/logger'
22
import { Separator, select } from '@socketsecurity/registry/lib/prompts'
3+
import { SocketSdkReturnType } from '@socketsecurity/sdk'
34

45
import constants from '../../constants'
56
import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api'
6-
import { setupSdk } from '../../utils/sdk'
7+
import { AuthError } from '../../utils/errors'
8+
import { getDefaultToken, setupSdk } from '../../utils/sdk'
79

810
import type { Choice } from '@socketsecurity/registry/lib/prompts'
911

@@ -12,51 +14,185 @@ type AuditChoice = Choice<string>
1214
type AuditChoices = (Separator | AuditChoice)[]
1315

1416
export async function getAuditLog({
15-
apiToken,
17+
logType,
1618
orgSlug,
17-
outputJson,
18-
outputMarkdown,
19+
outputKind,
1920
page,
20-
perPage,
21-
type
21+
perPage
2222
}: {
23-
apiToken: string
24-
outputJson: boolean
25-
outputMarkdown: boolean
23+
outputKind: 'json' | 'markdown' | 'print'
2624
orgSlug: string
2725
page: number
2826
perPage: number
29-
type: string
27+
logType: string
3028
}): Promise<void> {
31-
// Lazily access constants.spinner.
32-
const { spinner } = constants
29+
const apiToken = getDefaultToken()
30+
if (!apiToken) {
31+
throw new AuthError(
32+
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
33+
)
34+
}
3335

34-
spinner.start(`Looking up audit log for ${orgSlug}`)
36+
const auditLogs = await getAuditLogWithToken({
37+
apiToken,
38+
orgSlug,
39+
outputKind,
40+
page,
41+
perPage,
42+
logType
43+
})
44+
if (!auditLogs) return
3545

36-
const socketSdk = await setupSdk(apiToken)
37-
const result = await handleApiCall(
38-
socketSdk.getAuditLogEvents(orgSlug, {
39-
outputJson,
40-
outputMarkdown,
41-
orgSlug,
42-
type,
43-
page,
44-
per_page: perPage
45-
}),
46-
`Looking up audit log for ${orgSlug}\n`
47-
)
46+
if (outputKind === 'json')
47+
await outputAsJson(auditLogs.results, orgSlug, logType, page, perPage)
48+
else if (outputKind === 'markdown')
49+
await outputAsMarkdown(auditLogs.results, orgSlug, logType, page, perPage)
50+
else await outputAsPrint(auditLogs.results, orgSlug, logType)
51+
}
4852

49-
if (!result.success) {
50-
handleUnsuccessfulApiResponse('getAuditLogEvents', result, spinner)
53+
async function outputAsJson(
54+
auditLogs: SocketSdkReturnType<'getAuditLogEvents'>['data']['results'],
55+
orgSlug: string,
56+
logType: string,
57+
page: number,
58+
perPage: number
59+
): Promise<void> {
60+
let json
61+
try {
62+
json = JSON.stringify(
63+
{
64+
desc: 'Audit logs for given query',
65+
generated: new Date().toISOString(),
66+
org: orgSlug,
67+
logType,
68+
page,
69+
perPage,
70+
logs: auditLogs.map(log => {
71+
// Note: The subset is pretty arbitrary
72+
const {
73+
created_at,
74+
event_id,
75+
ip_address,
76+
type,
77+
user_agent,
78+
user_email
79+
} = log
80+
return {
81+
event_id,
82+
created_at,
83+
ip_address,
84+
type,
85+
user_agent,
86+
user_email
87+
}
88+
})
89+
},
90+
null,
91+
2
92+
)
93+
} catch (e) {
94+
logger.error(
95+
'There was a problem converting the logs to JSON, please try without the `--json` flag'
96+
)
97+
process.exitCode = 1
5198
return
5299
}
53100

54-
spinner.stop()
101+
logger.log(json)
102+
}
103+
104+
async function outputAsMarkdown(
105+
auditLogs: SocketSdkReturnType<'getAuditLogEvents'>['data']['results'],
106+
orgSlug: string,
107+
logType: string,
108+
page: number,
109+
perPage: number
110+
): Promise<void> {
111+
let md
112+
try {
113+
const table = mdTable(auditLogs, [
114+
'event_id',
115+
'created_at',
116+
'type',
117+
'user_email',
118+
'ip_address',
119+
'user_agent'
120+
])
121+
122+
md =
123+
`
124+
# Socket Audit Logs
125+
126+
These are the Socket.dev audit logs as per requested query.
127+
- org: ${orgSlug}
128+
- type filter: ${logType || '(none)'}
129+
- page: ${page}
130+
- per page: ${perPage}
131+
- generated: ${new Date().toISOString()}
132+
133+
${table}
134+
`.trim() + '\n'
135+
} catch (e) {
136+
logger.error(
137+
'There was a problem converting the logs to JSON, please try without the `--json` flag'
138+
)
139+
logger.error(e)
140+
process.exitCode = 1
141+
return
142+
}
55143

144+
logger.log(md)
145+
}
146+
147+
function mdTable<
148+
T extends SocketSdkReturnType<'getAuditLogEvents'>['data']['results']
149+
>(
150+
logs: T,
151+
// This is saying "an array of strings and the strings are a valid key of elements of T"
152+
// In turn, T is defined above as the audit log event type from our OpenAPI docs.
153+
cols: Array<string & keyof T[number]>
154+
): string {
155+
// Max col width required to fit all data in that column
156+
const cws = cols.map(col => col.length)
157+
158+
for (const log of logs) {
159+
for (let i = 0; i < cols.length; ++i) {
160+
// @ts-ignore
161+
const val: unknown = log[cols[i] ?? ''] ?? ''
162+
cws[i] = Math.max(cws[i] ?? 0, String(val).length)
163+
}
164+
}
165+
166+
let div = '|'
167+
for (const cw of cws) div += ' ' + '-'.repeat(cw) + ' |'
168+
169+
let header = '|'
170+
for (let i = 0; i < cols.length; ++i)
171+
header += ' ' + String(cols[i]).padEnd(cws[i] ?? 0, ' ') + ' |'
172+
173+
let body = ''
174+
for (const log of logs) {
175+
body += '|'
176+
for (let i = 0; i < cols.length; ++i) {
177+
// @ts-ignore
178+
const val: unknown = log[cols[i] ?? ''] ?? ''
179+
body += ' ' + String(val).padEnd(cws[i] ?? 0, ' ') + ' |'
180+
}
181+
body += '\n'
182+
}
183+
184+
return [div, header, div, body.trim(), div].filter(s => !!s.trim()).join('\n')
185+
}
186+
187+
async function outputAsPrint(
188+
auditLogs: SocketSdkReturnType<'getAuditLogEvents'>['data']['results'],
189+
orgSlug: string,
190+
logType: string
191+
): Promise<void> {
56192
const data: AuditChoices = []
57193
const logDetails: { [key: string]: string } = {}
58194

59-
for (const d of result.data.results) {
195+
for (const d of auditLogs) {
60196
const { created_at } = d
61197
if (created_at) {
62198
const name = `${new Date(created_at).toLocaleDateString('en-us', { year: 'numeric', month: 'numeric', day: 'numeric' })} - ${d.user_email} - ${d.type} - ${d.ip_address} - ${d.user_agent}`
@@ -68,12 +204,55 @@ export async function getAuditLog({
68204
logger.log(
69205
logDetails[
70206
(await select({
71-
message: type
72-
? `\n Audit log for: ${orgSlug} with type: ${type}\n`
207+
message: logType
208+
? `\n Audit log for: ${orgSlug} with type: ${logType}\n`
73209
: `\n Audit log for: ${orgSlug}\n`,
74210
choices: data,
75211
pageSize: 30
76212
})) as any
77213
]
78214
)
79215
}
216+
217+
async function getAuditLogWithToken({
218+
apiToken,
219+
logType,
220+
orgSlug,
221+
outputKind,
222+
page,
223+
perPage
224+
}: {
225+
apiToken: string
226+
outputKind: 'json' | 'markdown' | 'print'
227+
orgSlug: string
228+
page: number
229+
perPage: number
230+
logType: string
231+
}): Promise<SocketSdkReturnType<'getAuditLogEvents'>['data'] | void> {
232+
// Lazily access constants.spinner.
233+
const { spinner } = constants
234+
235+
spinner.start(`Looking up audit log for ${orgSlug}`)
236+
237+
const socketSdk = await setupSdk(apiToken)
238+
const result = await handleApiCall(
239+
socketSdk.getAuditLogEvents(orgSlug, {
240+
outputJson: outputKind === 'json', // I'm not sure this is used at all
241+
outputMarkdown: outputKind === 'markdown', // I'm not sure this is used at all
242+
orgSlug,
243+
type: logType,
244+
page,
245+
per_page: perPage
246+
}),
247+
`Looking up audit log for ${orgSlug}\n`
248+
)
249+
250+
if (!result.success) {
251+
handleUnsuccessfulApiResponse('getAuditLogEvents', result, spinner)
252+
return
253+
}
254+
255+
spinner.stop()
256+
257+
return result.data
258+
}

test/dry-run.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ describe('dry-run on all commands', async () => {
161161
expect(stderr).toMatchInlineSnapshot(`
162162
"\\x1b[31m\\xd7\\x1b[39m \\x1b[41m\\x1b[37mInput error\\x1b[39m\\x1b[49m: Please provide the required fields:
163163
164-
- Org name as the first argument \\x1b[31m(missing!)\\x1b[39m"
164+
- Org name as the first argument \\x1b[31m(missing!)\\x1b[39m"
165165
`)
166166

167167
expect(code).toBe(2)

0 commit comments

Comments
 (0)