Skip to content

Commit ef20c2c

Browse files
authoredNov 20, 2020
Merge pull request #1521 from snyk/feature/iac-detection-depth-CC-434
feat: Implement --detection-depth flag in iac command
2 parents 01e4032 + b60f1f3 commit ef20c2c

19 files changed

+179
-28
lines changed
 

‎help/commands-docs/iac-examples.md

+3
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@
77

88
- `Test terraform file`:
99
\$ snyk iac test /path/to/terraform_file.tf
10+
11+
- `Test matching files in a directory`:
12+
\$ snyk iac test /path/to/directory

‎help/commands-docs/iac.md

+8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ Find security issues in your Infrastructure as Code files.
1717

1818
## OPTIONS
1919

20+
- `--detection-depth`=<DEPTH>:
21+
(only in `test` command)
22+
Indicate the maximum depth of sub-directories to search. <DEPTH> must be a number.
23+
24+
Default: No Limit
25+
Example: `--detection-depth=3`
26+
Will limit search to provided directory (or current directory if no <PATH> provided) plus two levels of subdirectories.
27+
2028
- `--severity-threshold`=low|medium|high:
2129
Only report vulnerabilities of provided level or higher.
2230

‎package.json

-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"configstore": "^5.0.1",
7070
"debug": "^4.1.1",
7171
"diff": "^4.0.1",
72-
"glob": "^7.1.3",
7372
"graphlib": "^2.1.8",
7473
"inquirer": "^7.3.3",
7574
"lodash": "^4.17.20",

‎src/cli/args.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface ArgsOptions {
6868
// (see the snyk-mvn-plugin or snyk-gradle-plugin)
6969
_doubleDashArgs: string[];
7070
_: MethodArgs;
71-
[key: string]: boolean | string | MethodArgs | string[]; // The two last types are for compatibility only
71+
[key: string]: boolean | string | number | MethodArgs | string[]; // The two last types are for compatibility only
7272
}
7373

7474
export function args(rawArgv: string[]): Args {
@@ -227,6 +227,10 @@ export function args(rawArgv: string[]): Args {
227227
}
228228
}
229229

230+
if (argv.detectionDepth !== undefined) {
231+
argv.detectionDepth = Number(argv.detectionDepth);
232+
}
233+
230234
if (argv.skipUnresolved !== undefined) {
231235
if (argv.skipUnresolved === 'false') {
232236
argv.allowMissing = false;

‎src/cli/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
SupportedUserReachableFacingCliArgs,
4141
} from '../lib/types';
4242
import { SarifFileOutputEmptyError } from '../lib/errors/empty-sarif-output-error';
43+
import { InvalidDetectionDepthValue } from '../lib/errors/invalid-detection-depth-value';
4344

4445
const debug = Debug('snyk');
4546
const EXIT_CODES = {
@@ -262,6 +263,14 @@ async function main() {
262263
throw new FileFlagBadInputError();
263264
}
264265

266+
if (
267+
typeof args.options.detectionDepth !== 'undefined' &&
268+
(args.options.detectionDepth <= 0 ||
269+
Number.isNaN(args.options.detectionDepth))
270+
) {
271+
throw new InvalidDetectionDepthValue();
272+
}
273+
265274
validateUnsupportedSarifCombinations(args);
266275

267276
validateOutputFile(args.options, 'json', new JsonFileOutputBadInputError());

‎src/lib/detect.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function localFileSuppliedButNotFound(root, file) {
153153
);
154154
}
155155

156-
export function isLocalFolder(root) {
156+
export function isLocalFolder(root: string) {
157157
try {
158158
return fs.lstatSync(root).isDirectory();
159159
} catch (e) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { CustomError } from './custom-error';
2+
3+
export class InvalidDetectionDepthValue extends CustomError {
4+
constructor() {
5+
const msg = `Unsupported value for --detection-depth flag. Expected a positive integer.`;
6+
super(msg);
7+
this.code = 422;
8+
this.userMessage = msg;
9+
}
10+
}

‎src/lib/iac/detect-iac.ts

+35-25
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as fs from 'fs';
22
import * as pathLib from 'path';
33
import * as debugLib from 'debug';
4-
import * as glob from 'glob';
54
import { isLocalFolder, localFileSuppliedButNotFound } from '../detect';
65
import { CustomError } from '../errors';
76
import { validateK8sFile, makeValidateTerraformRequest } from './iac-parser';
@@ -10,6 +9,7 @@ import {
109
IacProjectType,
1110
IacFileTypes,
1211
} from './constants';
12+
import { makeDirectoryIterator } from './makeDirectoryIterator';
1313
import {
1414
SupportLocalFileOnlyIacError,
1515
UnsupportedOptionFileIacError,
@@ -38,7 +38,9 @@ export async function getProjectType(
3838
// scanning the projects - we need save the files we need to scan on the options
3939
// so we could create assembly payloads for the relevant files.
4040
// We are sending it as a `Multi IaC` project - and later assign the relevant type for each project
41-
const directoryFiles = await getDirectoryFiles(root);
41+
const directoryFiles = await getDirectoryFiles(root, {
42+
maxDepth: options.detectionDepth,
43+
});
4244
options.iacDirFiles = directoryFiles;
4345
return IacProjectType.MULTI_IAC;
4446
}
@@ -84,33 +86,41 @@ async function getProjectTypeForIacFile(filePath: string) {
8486
return projectType;
8587
}
8688

87-
async function getDirectoryFiles(root: string) {
89+
async function getDirectoryFiles(
90+
root: string,
91+
options: { maxDepth?: number } = {},
92+
) {
8893
const iacFiles: IacFileInDirectory[] = [];
8994
const dirPath = pathLib.resolve(root, '.');
90-
const files = glob.sync(
91-
pathLib.join(dirPath, '/**/**/*.+(json|yaml|yml|tf)'),
92-
);
95+
const supportedExtensions = new Set(Object.keys(projectTypeByFileType));
96+
97+
const directoryPaths = makeDirectoryIterator(dirPath, {
98+
maxDepth: options.maxDepth,
99+
});
93100

94-
for (const fileName of files) {
95-
const ext = pathLib.extname(fileName).substr(1);
96-
if (Object.keys(projectTypeByFileType).includes(ext)) {
97-
const filePath = pathLib.resolve(root, fileName);
101+
for (const filePath of directoryPaths) {
102+
const fileType = pathLib
103+
.extname(filePath)
104+
.substr(1)
105+
.toLowerCase() as IacFileTypes;
106+
if (!fileType || !supportedExtensions.has(fileType)) {
107+
continue;
108+
}
98109

99-
await getProjectTypeForIacFile(filePath)
100-
.then((projectType) => {
101-
iacFiles.push({
102-
filePath,
103-
projectType,
104-
fileType: ext as IacFileTypes,
105-
});
106-
})
107-
.catch((err: CustomError) => {
108-
iacFiles.push({
109-
filePath,
110-
fileType: ext as IacFileTypes,
111-
failureReason: err.userMessage,
112-
});
113-
});
110+
try {
111+
const projectType = await getProjectTypeForIacFile(filePath);
112+
iacFiles.push({
113+
filePath,
114+
projectType,
115+
fileType,
116+
});
117+
} catch (err) {
118+
iacFiles.push({
119+
filePath,
120+
fileType,
121+
failureReason:
122+
err instanceof CustomError ? err.userMessage : 'Unhandled Error',
123+
});
114124
}
115125
}
116126

‎src/lib/iac/makeDirectoryIterator.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as fs from 'fs';
2+
import { resolve } from 'path';
3+
4+
/**
5+
* Walk the provided directory tree, depth first, yielding the matched filename
6+
* to the caller. An optional `maxDepth` argument can be provided to limit how
7+
* deep in the file tree the search will go.
8+
*/
9+
export function* makeDirectoryIterator(
10+
root: string,
11+
options: { maxDepth?: number; includeDotfiles?: boolean } = {},
12+
) {
13+
if (!isDirectory(root)) {
14+
throw new Error(`Path "${root}" is not a directory`);
15+
}
16+
17+
// Internal function to allow us to track the recursion depth.
18+
function* walk(dirpath: string, currentDepth: number) {
19+
const filenames = fs.readdirSync(dirpath);
20+
for (const filename of filenames) {
21+
// NOTE: We filter filenames that start with a period to maintain
22+
// backwards compatibility with the original implementation that used the
23+
// "glob" package.
24+
if (!options.includeDotfiles && filename.startsWith('.')) {
25+
continue;
26+
}
27+
28+
const resolved = resolve(dirpath, filename);
29+
if (isDirectory(resolved)) {
30+
// Skip this directory if max depth has been reached.
31+
if (options.maxDepth === currentDepth) {
32+
continue;
33+
}
34+
35+
yield* walk(resolved, currentDepth + 1);
36+
} else {
37+
yield resolved;
38+
}
39+
}
40+
}
41+
42+
yield* walk(root, 1);
43+
}
44+
45+
// NOTE: We use isDirectory here instead of isLocalFolder() in order to
46+
// follow symlinks and match the original glob() implementation.
47+
function isDirectory(path: string): boolean {
48+
try {
49+
// statSync will resolve symlinks, lstatSync will not.
50+
return fs.statSync(path).isDirectory();
51+
} catch (e) {
52+
return false;
53+
}
54+
}

‎test/fixtures/iac/depth_detection/.hidden.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/.hidden/hidden.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/one.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/two/three/four/five/five.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/two/three/four/five/six/six.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/two/three/four/four.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/two/three/three.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/one/two/two.tf

Whitespace-only changes.

‎test/fixtures/iac/depth_detection/root.tf

Whitespace-only changes.

‎test/makeDirectoryIterator.spec.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { makeDirectoryIterator } from '../src/lib/iac/makeDirectoryIterator';
2+
import * as path from 'path';
3+
4+
describe('makeDirectoryIterator', () => {
5+
const fixturePath = path.join(__dirname, 'fixtures/iac/depth_detection');
6+
7+
it('iterates over all files in the directory tree', () => {
8+
const it = makeDirectoryIterator(fixturePath);
9+
const result = Array.from(it);
10+
11+
expect(result).toEqual([
12+
expect.stringContaining('one.tf'),
13+
expect.stringContaining('five.tf'),
14+
expect.stringContaining('six.tf'),
15+
expect.stringContaining('four.tf'),
16+
expect.stringContaining('three.tf'),
17+
expect.stringContaining('two.tf'),
18+
expect.stringContaining('root.tf'),
19+
]);
20+
});
21+
22+
it('includes dotfiles when the includeDotfiles flag is provided', () => {
23+
const it = makeDirectoryIterator(fixturePath, { includeDotfiles: true });
24+
const result = Array.from(it);
25+
26+
expect(result).toEqual([
27+
expect.stringContaining('hidden.tf'),
28+
expect.stringContaining('.hidden.tf'),
29+
expect.stringContaining('one.tf'),
30+
expect.stringContaining('five.tf'),
31+
expect.stringContaining('six.tf'),
32+
expect.stringContaining('four.tf'),
33+
expect.stringContaining('three.tf'),
34+
expect.stringContaining('two.tf'),
35+
expect.stringContaining('root.tf'),
36+
]);
37+
});
38+
39+
it('limits the subdirectory depth when maxDepth option is provided', () => {
40+
const it = makeDirectoryIterator(fixturePath, { maxDepth: 3 });
41+
const result = Array.from(it);
42+
43+
expect(result).toEqual([
44+
expect.stringContaining('one.tf'),
45+
expect.stringContaining('two.tf'),
46+
expect.stringContaining('root.tf'),
47+
]);
48+
});
49+
50+
it('throws an error if the path provided is not a directory', () => {
51+
const it = makeDirectoryIterator('missing_path');
52+
expect(() => Array.from(it)).toThrowError();
53+
});
54+
});

0 commit comments

Comments
 (0)
Please sign in to comment.