Skip to content

Commit 468122d

Browse files
authored
Merge pull request #1144 from microsoft/benibenj/fragile-horse
Scan for secrets and disallow .env files
2 parents aed259a + d897fe5 commit 468122d

File tree

15 files changed

+5674
-65
lines changed

15 files changed

+5674
-65
lines changed

package-lock.json

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

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
},
4040
"dependencies": {
4141
"@azure/identity": "^4.1.0",
42+
"@secretlint/node": "^9.3.2",
43+
"@secretlint/secretlint-formatter-sarif": "^9.3.2",
44+
"@secretlint/secretlint-rule-no-dotenv": "^9.3.2",
45+
"@secretlint/secretlint-rule-preset-recommend": "^9.3.2",
4246
"@vscode/vsce-sign": "^2.0.0",
4347
"azure-devops-node-api": "^12.5.0",
4448
"chalk": "^2.4.2",
@@ -55,6 +59,7 @@
5559
"minimatch": "^3.0.3",
5660
"parse-semver": "^1.1.1",
5761
"read": "^1.0.7",
62+
"secretlint": "^9.3.2",
5863
"semver": "^7.5.2",
5964
"tmp": "^0.2.3",
6065
"typed-rest-client": "^1.8.4",

src/package.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import * as GitHost from 'hosted-git-info';
2727
import parseSemver from 'parse-semver';
2828
import * as jsonc from 'jsonc-parser';
2929
import * as vsceSign from '@vscode/vsce-sign';
30+
import { lintFiles, lintText, prettyPrintLintResult } from './secretLint';
3031

3132
const MinimatchOptions: minimatch.IOptions = { dot: true };
3233

@@ -2106,4 +2107,39 @@ export async function printAndValidatePackagedFiles(files: IFile[], cwd: string,
21062107

21072108
message += '\n';
21082109
util.log.info(message);
2110+
2111+
await scanFilesForSecrets(files);
2112+
}
2113+
2114+
export async function scanFilesForSecrets(files: IFile[]): Promise<void> {
2115+
const onDiskFiles: ILocalFile[] = files.filter(file => !isInMemoryFile(file)) as ILocalFile[];
2116+
const inMemoryFiles: IInMemoryFile[] = files.filter(file => isInMemoryFile(file)) as IInMemoryFile[];
2117+
2118+
const onDiskResult = await lintFiles(onDiskFiles.map(file => file.localPath));
2119+
const inMemoryResults = await Promise.all(
2120+
inMemoryFiles.map(file => lintText(typeof file.contents === 'string' ? file.contents : file.contents.toString('utf8'), file.path))
2121+
);
2122+
2123+
const secretsFound = [...inMemoryResults, onDiskResult].filter(result => !result.ok).flatMap(result => result.results);
2124+
if (secretsFound.length === 0) {
2125+
return;
2126+
}
2127+
2128+
// secrets found
2129+
const noneDotEnvSecretsFound = secretsFound.filter(result => result.ruleId !== '@secretlint/secretlint-rule-no-dotenv');
2130+
if (noneDotEnvSecretsFound.length > 0) {
2131+
let errorOutput = '';
2132+
for (const secret of noneDotEnvSecretsFound) {
2133+
errorOutput += '\n' + prettyPrintLintResult(secret);
2134+
}
2135+
util.log.error(`Secrets have been detected in the files which are being packaged:\n\n${errorOutput}`);
2136+
}
2137+
2138+
// .env file found
2139+
const allRuleIds = new Set(secretsFound.map(result => result.ruleId).filter(Boolean));
2140+
if (allRuleIds.has('@secretlint/secretlint-rule-no-dotenv')) {
2141+
util.log.error(`${chalk.bold.red('.env')} files should not be packaged. Ignore them in your ${chalk.bold('.vscodeignore')} file or exclude them from the package.json ${chalk.bold('files')} property.`);
2142+
}
2143+
2144+
process.exit(1);
21092145
}

src/secretLint.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import chalk from "chalk";
2+
import { Convert, Location, Region, Result, Level } from "./typings/secret-lint-types";
3+
4+
interface SecretLintEngineResult {
5+
ok: boolean;
6+
output: string;
7+
}
8+
9+
interface SecretLintResult {
10+
ok: boolean;
11+
results: Result[];
12+
}
13+
14+
const lintConfig = {
15+
rules: [
16+
{
17+
id: "@secretlint/secretlint-rule-preset-recommend",
18+
rules: [
19+
{
20+
"id": "@secretlint/secretlint-rule-basicauth",
21+
"allowMessageIds": ["BasicAuth"]
22+
}
23+
]
24+
}, {
25+
id: "@secretlint/secretlint-rule-no-dotenv"
26+
}
27+
28+
]
29+
};
30+
31+
const lintOptions = {
32+
configFileJSON: lintConfig,
33+
formatter: "@secretlint/secretlint-formatter-sarif", // checkstyle, compact, jslint-xml, junit, pretty-error, stylish, tap, unix, json, mask-result, table
34+
color: true,
35+
maskSecrets: false
36+
};
37+
38+
// Helper function to dynamically import the createEngine function
39+
async function getEngine() {
40+
// Use a raw dynamic import that will not be transformed
41+
// This is necessary because @secretlint/node is an ESM module
42+
const secretlintModule = await eval('import("@secretlint/node")');
43+
const engine = await secretlintModule.createEngine(lintOptions);
44+
return engine;
45+
}
46+
47+
export async function lintFiles(
48+
filePaths: string[]
49+
): Promise<SecretLintResult> {
50+
const engine = await getEngine();
51+
52+
const engineResult = await engine.executeOnFiles({
53+
filePathList: filePaths
54+
});
55+
return parseResult(engineResult);
56+
}
57+
58+
export async function lintText(
59+
content: string,
60+
fileName: string
61+
): Promise<SecretLintResult> {
62+
const engine = await getEngine();
63+
64+
const engineResult = await engine.executeOnContent({
65+
content,
66+
filePath: fileName
67+
});
68+
return parseResult(engineResult);
69+
}
70+
71+
function parseResult(result: SecretLintEngineResult): SecretLintResult {
72+
const output = Convert.toSecretLintOutput(result.output);
73+
const results = output.runs.at(0)?.results ?? [];
74+
return { ok: result.ok, results };
75+
}
76+
77+
export function prettyPrintLintResult(result: Result): string {
78+
if (!result.message.text) {
79+
return JSON.stringify(result);
80+
}
81+
82+
const text = result.message.text;
83+
const titleColor = result.level === undefined || result.level === Level.Error ? chalk.bold.red : chalk.bold.yellow;
84+
const title = text.length > 54 ? text.slice(0, 50) + '...' : text;
85+
let output = `\t${titleColor(title)}\n`;
86+
87+
if (result.locations) {
88+
result.locations.forEach(location => {
89+
output += `\t${prettyPrintLocation(location)}\n`;
90+
});
91+
}
92+
return output;
93+
}
94+
95+
function prettyPrintLocation(location: Location): string {
96+
if (!location.physicalLocation) { return JSON.stringify(location); }
97+
98+
const uri = location.physicalLocation.artifactLocation?.uri;
99+
if (!uri) { return JSON.stringify(location); }
100+
101+
let output = uri;
102+
103+
const region = location.physicalLocation.region;
104+
const regionStringified = region ? prettyPrintRegion(region) : undefined;
105+
if (regionStringified) {
106+
output += `#${regionStringified}`;
107+
}
108+
109+
return output;
110+
}
111+
112+
function prettyPrintRegion(region: Region): string | undefined {
113+
const startPosition = prettyPrintPosition(region.startLine, region.startColumn);
114+
const endPosition = prettyPrintPosition(region.endLine, region.endColumn);
115+
116+
if (!startPosition) {
117+
return undefined;
118+
}
119+
120+
let output = startPosition;
121+
if (endPosition && startPosition !== endPosition) {
122+
output += `-${endPosition}`;
123+
}
124+
125+
return output;
126+
}
127+
128+
function prettyPrintPosition(line: number | undefined, column: number | undefined): string | undefined {
129+
if (line === undefined) {
130+
return undefined;
131+
}
132+
let output: string = line.toString();
133+
if (column !== undefined) {
134+
output += `:${column}`;
135+
}
136+
137+
return output;
138+
}

src/test/fixtures/env/.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
A=1
2+
B=2
3+
C=3

src/test/fixtures/env/LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
LICENSE...

src/test/fixtures/env/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Test

src/test/fixtures/env/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
// Here a Fibonacci sequence function
3+
export function fib(n: number): number {
4+
if (n <= 1) return n;
5+
return fib(n - 1) + fib(n - 2);
6+
}

src/test/fixtures/env/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "uuid",
3+
"publisher": "joaomoreno",
4+
"version": "1.0.0",
5+
"engines": {
6+
"vscode": "*"
7+
},
8+
"files": [
9+
"main.ts",
10+
"package.json",
11+
"LICENSE",
12+
"README.md",
13+
".env"
14+
]
15+
}

src/test/fixtures/secret/LICENSE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
LICENSE...

src/test/fixtures/secret/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Test

src/test/fixtures/secret/main.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const k = {
2+
'type': 'service_account',
3+
'project_id': 'my-gcp-project',
4+
'private_key_id': 'abcdef1234567890abcdef1234567890abcdef12',
5+
'private_key': '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkq...\n-----END PRIVATE KEY-----\n',
6+
'client_email': '[email protected]',
7+
'client_id': '123456789012345678901',
8+
'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
9+
'token_uri': 'https://oauth2.googleapis.com/token',
10+
'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs',
11+
'client_x509_cert_url': 'https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my-gcp-project.iam.gserviceaccount.com'
12+
};
13+
14+
// Here a Fibonacci sequence function
15+
export function fib(n: number): number {
16+
if (n <= 1) return n;
17+
return fib(n - 1) + fib(n - 2);
18+
}

src/test/fixtures/secret/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "uuid",
3+
"publisher": "joaomoreno",
4+
"version": "1.0.0",
5+
"engines": {
6+
"vscode": "*"
7+
},
8+
"files": [
9+
"main.ts",
10+
"package.json",
11+
"LICENSE",
12+
"README.md"
13+
]
14+
}

src/test/package.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,25 @@ async function testPrintAndValidatePackagedFiles(files: IFile[], cwd: string, ma
146146
}
147147
}
148148

149+
async function processExitExpected(fn: () => Promise<any>, errorMessage: string): Promise<void> {
150+
const originalExit = process.exit;
151+
let exitCalled = false;
152+
153+
try {
154+
process.exit = (() => {
155+
exitCalled = true;
156+
throw new Error('Process exit was called');
157+
}) as any;
158+
159+
await fn();
160+
assert.fail(errorMessage);
161+
} catch (error) {
162+
assert.ok(exitCalled, errorMessage);
163+
} finally {
164+
process.exit = originalExit;
165+
}
166+
}
167+
149168
describe('collect', function () {
150169
this.timeout(60000);
151170

@@ -351,6 +370,16 @@ describe('collect', function () {
351370
const manifest = await readManifest(cwd);
352371
await collect(manifest, { cwd });
353372
});
373+
374+
it('should not package .env file', async function () {
375+
const cwd = fixture('env');
376+
await processExitExpected(() => pack({ cwd }), 'Expected package to throw: .env file should not be packaged');
377+
});
378+
379+
it('should not package file which has a private key', async function () {
380+
const cwd = fixture('secret');
381+
await processExitExpected(() => pack({ cwd }), 'Expected package to throw: file which has a private key should not be packaged');
382+
});
354383
});
355384

356385
describe('readManifest', () => {

0 commit comments

Comments
 (0)