Skip to content

Commit 923645e

Browse files
committed
Add socket sbom command and socket sbom scala
1 parent c9c1a20 commit 923645e

File tree

4 files changed

+275
-0
lines changed

4 files changed

+275
-0
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ use of the `projectIgnorePaths` to excludes files when creating a report.
8585

8686
## Contributing
8787

88+
### Setup
89+
90+
To run dev locally you can run these steps
91+
92+
```
93+
npm install
94+
npm run build:dist
95+
npm exec socket
96+
```
97+
98+
That should invoke it from local sources. If you make changes you run `build:dist` again.
99+
88100
### Environment variables for development
89101

90102
- `SOCKET_SECURITY_API_BASE_URL` - if set, this will be the base for all

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export * from './dependencies'
1818
export * from './analytics'
1919
export * from './diff-scan'
2020
export * from './threat-feed'
21+
export * from './sbom'

src/commands/sbom/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import meow from 'meow'
2+
import { meowWithSubcommands } from '../../utils/meow-with-subcommands'
3+
4+
import { scala } from './scala'
5+
6+
import type { CliSubcommand } from '../../utils/meow-with-subcommands'
7+
8+
const description = 'Generate a "Software Bill of Materials" for given file or dir'
9+
const help = (name:string) => `
10+
Usage
11+
12+
$ ${name} <language> <target>
13+
14+
Generates the SBOM ("Software Bill of Materials") like a package.json for
15+
Node.JS or requirements.txt for PyPi, but for certain supported ecosystems
16+
where it's common to use a dynamic SBOM definition, like Scala's sbt.
17+
18+
Only certain languages are supported and there may be language specific
19+
configurations available. See \`sbom <language> --help\` for usage details
20+
per language.
21+
22+
Currently supported language: scala
23+
24+
Examples
25+
26+
$ ${name} ./build.sbt
27+
`;
28+
29+
export const sbom: CliSubcommand = {
30+
description,
31+
async run(argv, importMeta, { parentName }) {
32+
const name = `${parentName} sbom`
33+
34+
// Note: this won't catch `socket sbom -xyz --help` sort of cases which
35+
// would fallback to the default meow help behavior. That's fine.
36+
if (argv.length === 0 || argv[0] === '--help') {
37+
meow(
38+
help(name),
39+
{
40+
argv: ['--help'] as const, // meow will exit() when --help is passed
41+
description,
42+
importMeta
43+
}
44+
)
45+
}
46+
47+
// argv = argv.filter(o => o !== '--help');
48+
await meowWithSubcommands(
49+
{
50+
scala
51+
},
52+
{
53+
argv,
54+
description,
55+
importMeta,
56+
name
57+
}
58+
)
59+
}
60+
}

src/commands/sbom/scala.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import util from 'node:util';
2+
import fs from 'node:fs';
3+
import child_process from 'node:child_process';
4+
import path from 'node:path';
5+
6+
import meow from 'meow'
7+
import { getFlagListOutput } from '../../utils/output-formatting.ts'
8+
import { Spinner } from '@socketsecurity/registry/lib/spinner'
9+
10+
import type { CliSubcommand } from '../../utils/meow-with-subcommands'
11+
12+
type ListDescription = string | { description: string, type?: string, default?: string }
13+
14+
const execp = util.promisify(child_process.exec);
15+
const renamep = util.promisify(fs.rename);
16+
17+
const description = 'Generate a "Software Bill of Materials" (`pom.xml`) from Scala\'s `build.sbt` file'
18+
19+
const sbomFlags: Record<string, ListDescription> = {
20+
bin: {
21+
type: 'string',
22+
default: 'sbt',
23+
description: 'Location of sbt binary to use'
24+
},
25+
out: {
26+
type: 'string',
27+
default: './socket.pom.xml',
28+
description: 'Path of output file; where to store the resulting sbom, see also --stdout'
29+
},
30+
stdout: {
31+
type: 'boolean',
32+
description: 'Print resulting pom.xml to stdout (supersedes --out)'
33+
},
34+
sbtOpts: {
35+
type: 'string',
36+
default: '',
37+
description: 'Additional options to pass on to sbt, as per `sbt --help`'
38+
},
39+
verbose: {
40+
type: 'boolean',
41+
description: 'Print debug messages'
42+
}
43+
}
44+
45+
const help = (name:string, flags: Record<string, ListDescription>) => `
46+
Usage
47+
$ ${name} [--sbt=path/to/sbt/binary] [--out=path/to/result] FILE|DIR
48+
49+
Options
50+
${getFlagListOutput(flags, 6)}
51+
52+
Uses \`sbt makePom\` to generate a \`pom.xml\` from your \`build.sbt\` file.
53+
This xml file is the SBOM ("Software Bill of Materials") like a package.json
54+
for Node.js or requirements.txt for PyPi, but specifically for Scala.
55+
56+
There are some caveats with \`build.sbt\` to \`pom.xml\` conversion:
57+
58+
- the xml is exported as socket.pom.xml as to not confuse existing build tools
59+
but it will first hit your /target/sbt<version> folder (as a different name)
60+
61+
- the pom.xml format (standard by Scala) does not support certain sbt features
62+
- \`excludeAll()\`, \`dependencyOverrides\`, \`force()\`, \`relativePath\`
63+
- For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html
64+
65+
- it uses your sbt settings and local configuration verbatim
66+
67+
- it can only export one target per run, so if you have multiple targets like
68+
development and production, you must run them separately.
69+
70+
You can optionally configure the path to the \`sbt\` bin to invoke.
71+
72+
Examples
73+
74+
$ ${name} ./build.sbt
75+
$ ${name} --bin=/usr/bin/sbt ./build.sbt
76+
`;
77+
78+
export const scala: CliSubcommand = {
79+
description,
80+
async run(argv, importMeta, { parentName }) {
81+
const name = `${parentName} scala`
82+
// note: meow will exit if it prints the --help screen
83+
const cli = meow(
84+
help(name, sbomFlags),
85+
{
86+
argv: argv.length === 0 ? ['--help'] : argv,
87+
description,
88+
importMeta
89+
}
90+
)
91+
92+
const target = cli.input[0]
93+
94+
if (!target) {
95+
// will exit.
96+
new Spinner().start('Parsing...').error(`Failure: Missing FILE|DIR argument. See \`${name} --help\` for details.`);
97+
process.exit(1);
98+
}
99+
100+
if (cli.input.length > 1) {
101+
// will exit.
102+
new Spinner().start('Parsing...').error(`Failure: Can only accept one FILE or DIR, received ${cli.input.length} (make sure to escape spaces!). See \`${name} --help\` for details.`);
103+
process.exit(1);
104+
}
105+
106+
let bin:string = 'sbt'
107+
if (cli.flags['bin']) {
108+
bin = cli.flags['bin'] as string
109+
}
110+
111+
let out:string = './socket.pom.xml'
112+
if (cli.flags['out']) {
113+
out = cli.flags['out'] as string
114+
}
115+
if (cli.flags['stdout']) {
116+
out = '-';
117+
}
118+
119+
// TODO: we can make `-` (accept from stdin) work by storing it into /tmp
120+
if (target === '-') {
121+
new Spinner().start('Parsing...').error(`Failure: Currently source code from stdin is not supported. See \`${name} --help\` for details.`);
122+
process.exit(1);
123+
}
124+
125+
const verbose = cli.flags['verbose'] as boolean ?? false;
126+
127+
let sbtOpts:Array<string> = [];
128+
if (cli.flags['sbtOpts']) {
129+
sbtOpts = (cli.flags['sbtOpts'] as string).split(' ').map(s => s.trim()).filter(Boolean)
130+
}
131+
132+
await startConversion(target, bin, out, verbose, sbtOpts);
133+
}
134+
}
135+
136+
async function startConversion(target: string, bin: string, out: string, verbose: boolean, sbtOpts: Array<string>) {
137+
const spinner = new Spinner();
138+
139+
const rbin = path.resolve(bin);
140+
const rtarget = path.resolve(target);
141+
const rout = out === '-' ? '-' : path.resolve(out);
142+
143+
if (verbose){
144+
spinner.clear();
145+
console.log(`- Absolute bin path: \`${rbin}\``);
146+
console.log(`- Absolute target path: \`${rtarget}\``);
147+
console.log(`- Absolute out path: \`${rout}\``);
148+
}
149+
150+
spinner.start(`Running sbt from \`${bin}\` on \`${target}\`...`)
151+
152+
try {
153+
// We must now run sbt, pick the generated xml from the /target folder (the stdout should tell you the location upon success) and store it somewhere else.
154+
// TODO: Not sure what this somewhere else might be tbh.
155+
156+
const output = await execp(bin +` makePom ${sbtOpts.join(' ')}`, {cwd: target || '.'});
157+
spinner.success();
158+
if (verbose) {
159+
console.group('sbt stdout:')
160+
console.log(output);
161+
console.groupEnd();
162+
}
163+
164+
if (output.stderr) {
165+
spinner.error('There were errors while running sbt');
166+
// (In verbose mode, stderr was printed above, no need to repeat it)
167+
if (!verbose) console.error(output.stderr);
168+
process.exit(1);
169+
}
170+
171+
const loc = output.stdout?.match(/Wrote (.*?.pom)\n/)?.[1]?.trim();
172+
if (!loc) {
173+
spinner.error('There were no errors from sbt but could not find the location of resulting .pom file either');
174+
process.exit(1);
175+
}
176+
177+
// Move the pom file to ...? initial cwd? loc will be an absolute path, or dump to stdout
178+
if (out === '-') {
179+
spinner.start('Result:\n```').success();
180+
console.log(fs.readFileSync(loc, 'utf8'));
181+
console.log('```')
182+
spinner.start().success(`OK`);
183+
} else {
184+
if (verbose) {
185+
spinner.start(`Moving sbom file from \`${loc.replace(/^\/home\/[^\/]*?\//, '~/')}\` to \`${out}\``);
186+
} else {
187+
spinner.start('Moving output pom file');
188+
}
189+
// TODO: do we prefer fs-extra? renaming can be gnarly on windows and fs-extra's version is better
190+
await renamep(loc, out);
191+
spinner.success();
192+
spinner.start().success(`OK. File should be available in \`${out}\``);
193+
}
194+
} catch (e) {
195+
spinner.error('There was an unexpected error while running this')
196+
if (verbose) {
197+
console.log(e);
198+
}
199+
process.exit(1);
200+
}
201+
202+
}

0 commit comments

Comments
 (0)