@netlify/build 33.2.1 → 33.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/env/changes.d.ts +1 -0
- package/lib/log/messages/core_steps.d.ts +2 -1
- package/lib/log/messages/core_steps.js +17 -15
- package/lib/plugins_core/blobs_upload/index.js +1 -2
- package/lib/plugins_core/dev_blobs_upload/index.js +1 -2
- package/lib/plugins_core/secrets_scanning/index.js +26 -12
- package/lib/plugins_core/secrets_scanning/utils.d.ts +17 -14
- package/lib/plugins_core/secrets_scanning/utils.js +229 -25
- package/package.json +16 -16
package/lib/env/changes.d.ts
CHANGED
|
@@ -15,8 +15,9 @@ export function logFunctionsToBundle({ logs, userFunctions, userFunctionsSrc, us
|
|
|
15
15
|
}): void;
|
|
16
16
|
export function logSecretsScanSkipMessage(logs: any, msg: any): void;
|
|
17
17
|
export function logSecretsScanSuccessMessage(logs: any, msg: any): void;
|
|
18
|
-
export function logSecretsScanFailBuildMessage({ logs, scanResults, groupedResults }: {
|
|
18
|
+
export function logSecretsScanFailBuildMessage({ logs, scanResults, groupedResults, enhancedScanShouldRunInActiveMode, }: {
|
|
19
19
|
logs: any;
|
|
20
20
|
scanResults: any;
|
|
21
21
|
groupedResults: any;
|
|
22
|
+
enhancedScanShouldRunInActiveMode: any;
|
|
22
23
|
}): void;
|
|
@@ -93,11 +93,11 @@ export const logSecretsScanSkipMessage = function (logs, msg) {
|
|
|
93
93
|
export const logSecretsScanSuccessMessage = function (logs, msg) {
|
|
94
94
|
log(logs, msg, { color: THEME.highlightWords });
|
|
95
95
|
};
|
|
96
|
-
export const logSecretsScanFailBuildMessage = function ({ logs, scanResults, groupedResults }) {
|
|
96
|
+
export const logSecretsScanFailBuildMessage = function ({ logs, scanResults, groupedResults, enhancedScanShouldRunInActiveMode, }) {
|
|
97
97
|
const { secretMatches, enhancedSecretMatches } = groupedResults;
|
|
98
98
|
const secretMatchesKeys = Object.keys(secretMatches);
|
|
99
99
|
const enhancedSecretMatchesKeys = Object.keys(enhancedSecretMatches);
|
|
100
|
-
logErrorSubHeader(logs, `Scanning complete. ${scanResults.scannedFilesCount} file(s) scanned. Secrets scanning found ${secretMatchesKeys.length} instance(s) of secrets${enhancedSecretMatchesKeys.length > 0 ? ` and ${enhancedSecretMatchesKeys.length} instance(s) of likely secrets` : ''} in build output or repo code.\n`);
|
|
100
|
+
logErrorSubHeader(logs, `Scanning complete. ${scanResults.scannedFilesCount} file(s) scanned. Secrets scanning found ${secretMatchesKeys.length} instance(s) of secrets${enhancedSecretMatchesKeys.length > 0 && enhancedScanShouldRunInActiveMode ? ` and ${enhancedSecretMatchesKeys.length} instance(s) of likely secrets` : ''} in build output or repo code.\n`);
|
|
101
101
|
// Explicit secret matches
|
|
102
102
|
secretMatchesKeys.forEach((key) => {
|
|
103
103
|
logError(logs, `Secret env var "${key}"'s value detected:`);
|
|
@@ -113,20 +113,22 @@ export const logSecretsScanFailBuildMessage = function ({ logs, scanResults, gro
|
|
|
113
113
|
logError(logs, `\nTo prevent exposing secrets, the build will fail until these secret values are not found in build output or repo files.`);
|
|
114
114
|
logError(logs, `\nIf these are expected, use SECRETS_SCAN_OMIT_PATHS, SECRETS_SCAN_OMIT_KEYS, or SECRETS_SCAN_ENABLED to prevent detecting.`);
|
|
115
115
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
if (enhancedScanShouldRunInActiveMode) {
|
|
117
|
+
// Likely secret matches from enhanced scan
|
|
118
|
+
enhancedSecretMatchesKeys.forEach((key, index) => {
|
|
119
|
+
logError(logs, `${index === 0 && secretMatchesKeys.length ? '\n' : ''}"${key}***" detected as a likely secret:`);
|
|
120
|
+
enhancedSecretMatches[key]
|
|
121
|
+
.sort((a, b) => {
|
|
122
|
+
return a.file > b.file ? 0 : 1;
|
|
123
|
+
})
|
|
124
|
+
.forEach(({ lineNumber, file }) => {
|
|
125
|
+
logError(logs, `found value at line ${lineNumber} in ${file}`, { indent: true });
|
|
126
|
+
});
|
|
125
127
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
if (enhancedSecretMatchesKeys.length) {
|
|
129
|
+
logError(logs, `\nTo prevent exposing secrets, the build will fail until these likely secret values are not found in build output or repo files.`);
|
|
130
|
+
logError(logs, `\nIf these are expected, use SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES, or SECRETS_SCAN_SMART_DETECTION_ENABLED to prevent detecting.`);
|
|
131
|
+
}
|
|
130
132
|
}
|
|
131
133
|
logError(logs, `\nFor more information on secrets scanning, see the Netlify Docs: https://ntl.fyi/configure-secrets-scanning`);
|
|
132
134
|
};
|
|
@@ -40,8 +40,7 @@ const coreStep = async function ({ logs, deployId, buildDir, packagePath, consta
|
|
|
40
40
|
await pMap(blobsToUpload, async ({ key, contentPath, metadataPath }) => {
|
|
41
41
|
systemLog(`Uploading blob ${key}`);
|
|
42
42
|
const { data, metadata } = await getFileWithMetadata(key, contentPath, metadataPath);
|
|
43
|
-
|
|
44
|
-
await blobStore.set(key, arrayBuffer, { metadata });
|
|
43
|
+
await blobStore.set(key, new Blob([data]), { metadata });
|
|
45
44
|
}, { concurrency: 10 });
|
|
46
45
|
}
|
|
47
46
|
catch (err) {
|
|
@@ -47,8 +47,7 @@ const coreStep = async function ({ debug, logs, deployId, buildDir, quiet, packa
|
|
|
47
47
|
log(logs, `- Uploading blob ${key}`, { indent: true });
|
|
48
48
|
}
|
|
49
49
|
const { data, metadata } = await getFileWithMetadata(key, contentPath, metadataPath);
|
|
50
|
-
|
|
51
|
-
await blobStore.set(key, arrayBuffer, { metadata });
|
|
50
|
+
await blobStore.set(key, new Blob([data]), { metadata });
|
|
52
51
|
}, { concurrency: 10 });
|
|
53
52
|
}
|
|
54
53
|
catch (err) {
|
|
@@ -5,10 +5,14 @@ import { logSecretsScanFailBuildMessage, logSecretsScanSkipMessage, logSecretsSc
|
|
|
5
5
|
import { reportValidations } from '../../status/validations.js';
|
|
6
6
|
import { getFilePathsToScan, getOmitValuesFromEnhancedScanForEnhancedScanFromEnv, getSecretKeysToScanFor, groupScanResultsByKeyAndScanType, isEnhancedSecretsScanningEnabled, isSecretsScanningEnabled, scanFilesForKeyValues, } from './utils.js';
|
|
7
7
|
const tracer = trace.getTracer('secrets-scanning');
|
|
8
|
-
const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecretKeys, enhancedSecretScan, systemLog, deployId, api, }) {
|
|
8
|
+
const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecretKeys, enhancedSecretScan, featureFlags, systemLog, deployId, api, }) {
|
|
9
9
|
const stepResults = {};
|
|
10
10
|
const passedSecretKeys = (explicitSecretKeys || '').split(',');
|
|
11
11
|
const envVars = netlifyConfig.build.environment;
|
|
12
|
+
// When the flag is disabled, we may still run the scan if a secrets scan would otherwise take place anyway
|
|
13
|
+
// In this case, we hide any output to the user and simply gather the information in our logs
|
|
14
|
+
const enhancedScanShouldRunInActiveMode = featureFlags?.enhanced_secret_scan_impacts_builds ?? false;
|
|
15
|
+
const useMinimalChunks = featureFlags?.secret_scanning_minimal_chunks;
|
|
12
16
|
systemLog?.({ passedSecretKeys, buildDir });
|
|
13
17
|
if (!isSecretsScanningEnabled(envVars)) {
|
|
14
18
|
logSecretsScanSkipMessage(logs, 'Secrets scanning disabled via SECRETS_SCAN_ENABLED flag set to false.');
|
|
@@ -22,16 +26,21 @@ const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecret
|
|
|
22
26
|
log(logs, `SECRETS_SCAN_OMIT_PATHS override option set to: ${envVars['SECRETS_SCAN_OMIT_PATHS']}\n`);
|
|
23
27
|
}
|
|
24
28
|
const enhancedScanningEnabledInEnv = isEnhancedSecretsScanningEnabled(envVars);
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
const enhancedScanConfigured = enhancedSecretScan && enhancedScanningEnabledInEnv;
|
|
30
|
+
if (enhancedSecretScan && enhancedScanShouldRunInActiveMode && !enhancedScanningEnabledInEnv) {
|
|
31
|
+
logSecretsScanSkipMessage(logs, 'Enhanced secrets detection disabled via SECRETS_SCAN_SMART_DETECTION_ENABLED flag set to false.');
|
|
27
32
|
}
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
envVars['
|
|
31
|
-
log(logs, `
|
|
33
|
+
if (enhancedScanShouldRunInActiveMode &&
|
|
34
|
+
enhancedScanConfigured &&
|
|
35
|
+
envVars['SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES'] !== undefined) {
|
|
36
|
+
log(logs, `SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES override option set to: ${envVars['SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES']}\n`);
|
|
32
37
|
}
|
|
33
38
|
const keysToSearchFor = getSecretKeysToScanFor(envVars, passedSecretKeys);
|
|
34
|
-
if
|
|
39
|
+
// In passive mode, only run the enhanced scan if we have explicit secret keys
|
|
40
|
+
const enhancedScanShouldRun = enhancedScanShouldRunInActiveMode
|
|
41
|
+
? enhancedScanConfigured
|
|
42
|
+
: enhancedScanConfigured && keysToSearchFor.length > 0;
|
|
43
|
+
if (keysToSearchFor.length === 0 && !enhancedScanShouldRun) {
|
|
35
44
|
logSecretsScanSkipMessage(logs, 'Secrets scanning skipped because no env vars marked as secret are set to non-empty/non-trivial values or they are all omitted with SECRETS_SCAN_OMIT_KEYS env var setting.');
|
|
36
45
|
return stepResults;
|
|
37
46
|
}
|
|
@@ -52,8 +61,9 @@ const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecret
|
|
|
52
61
|
keys: keysToSearchFor,
|
|
53
62
|
base: buildDir,
|
|
54
63
|
filePaths,
|
|
55
|
-
enhancedScanning:
|
|
64
|
+
enhancedScanning: enhancedScanShouldRun,
|
|
56
65
|
omitValuesFromEnhancedScan: getOmitValuesFromEnhancedScanForEnhancedScanFromEnv(envVars),
|
|
66
|
+
useMinimalChunks,
|
|
57
67
|
});
|
|
58
68
|
secretMatches = scanResults.matches.filter((match) => !match.enhancedMatch);
|
|
59
69
|
enhancedSecretMatches = scanResults.matches.filter((match) => match.enhancedMatch);
|
|
@@ -65,7 +75,8 @@ const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecret
|
|
|
65
75
|
secretsFilesCount: scanResults.scannedFilesCount,
|
|
66
76
|
keysToSearchFor,
|
|
67
77
|
enhancedPrefixMatches: enhancedSecretMatches.length ? enhancedSecretMatches.map((match) => match.key) : [],
|
|
68
|
-
enhancedScanning:
|
|
78
|
+
enhancedScanning: enhancedScanShouldRun,
|
|
79
|
+
enhancedScanActiveMode: enhancedScanShouldRunInActiveMode,
|
|
69
80
|
};
|
|
70
81
|
systemLog?.(attributesForLogsAndSpan);
|
|
71
82
|
span.setAttributes(attributesForLogsAndSpan);
|
|
@@ -75,11 +86,13 @@ const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecret
|
|
|
75
86
|
const secretScanResult = {
|
|
76
87
|
scannedFilesCount: scanResults?.scannedFilesCount ?? 0,
|
|
77
88
|
secretsScanMatches: secretMatches ?? [],
|
|
78
|
-
enhancedSecretsScanMatches: enhancedSecretMatches
|
|
89
|
+
enhancedSecretsScanMatches: enhancedScanShouldRunInActiveMode && enhancedSecretMatches ? enhancedSecretMatches : [],
|
|
79
90
|
};
|
|
80
91
|
reportValidations({ api, secretScanResult, deployId, systemLog });
|
|
81
92
|
}
|
|
82
|
-
if (!scanResults ||
|
|
93
|
+
if (!scanResults ||
|
|
94
|
+
scanResults.matches.length === 0 ||
|
|
95
|
+
(!enhancedScanShouldRunInActiveMode && !secretMatches?.length)) {
|
|
83
96
|
logSecretsScanSuccessMessage(logs, `Secrets scanning complete. ${scanResults?.scannedFilesCount} file(s) scanned. No secrets detected in build output or repo code!`);
|
|
84
97
|
return stepResults;
|
|
85
98
|
}
|
|
@@ -89,6 +102,7 @@ const coreStep = async function ({ buildDir, logs, netlifyConfig, explicitSecret
|
|
|
89
102
|
logs,
|
|
90
103
|
scanResults,
|
|
91
104
|
groupedResults: groupScanResultsByKeyAndScanType(scanResults),
|
|
105
|
+
enhancedScanShouldRunInActiveMode,
|
|
92
106
|
});
|
|
93
107
|
const error = new Error(`Secrets scanning found secrets in build.`);
|
|
94
108
|
addErrorInfo(error, { type: 'secretScanningFoundSecrets' });
|
|
@@ -9,6 +9,7 @@ interface ScanArgs {
|
|
|
9
9
|
filePaths: string[];
|
|
10
10
|
enhancedScanning?: boolean;
|
|
11
11
|
omitValuesFromEnhancedScan?: unknown[];
|
|
12
|
+
useMinimalChunks: boolean;
|
|
12
13
|
}
|
|
13
14
|
interface MatchResult {
|
|
14
15
|
lineNumber: number;
|
|
@@ -49,32 +50,34 @@ export declare function getOmitValuesFromEnhancedScanForEnhancedScanFromEnv(env:
|
|
|
49
50
|
*/
|
|
50
51
|
export declare function getSecretKeysToScanFor(env: Record<string, unknown>, secretKeys: string[]): string[];
|
|
51
52
|
/**
|
|
52
|
-
* Checks a
|
|
53
|
+
* Checks a chunk of text for likely secrets based on known prefixes and patterns.
|
|
53
54
|
* The function works by:
|
|
54
|
-
* 1. Splitting the
|
|
55
|
+
* 1. Splitting the chunk into tokens using quotes, whitespace, equals signs, colons, and commas as delimiters
|
|
55
56
|
* 2. For each token, checking if it matches our secret pattern:
|
|
56
57
|
* - Must start (^) with one of our known prefixes (e.g. aws_, github_pat_, etc)
|
|
57
58
|
* - Must be followed by at least MIN_CHARS_AFTER_PREFIX non-whitespace characters
|
|
58
59
|
* - Must extend to the end ($) of the token
|
|
59
60
|
*
|
|
60
|
-
* For example, given the
|
|
61
|
+
* For example, given the chunk: secretKey='aws_123456789012345678'
|
|
61
62
|
* 1. It's split into tokens: ['secretKey', 'aws_123456789012345678']
|
|
62
63
|
* 2. Each token is checked against the regex pattern:
|
|
63
64
|
* - 'secretKey' doesn't match (doesn't start with a known prefix)
|
|
64
65
|
* - 'aws_123456789012345678' matches (starts with 'aws_' and has sufficient length)
|
|
65
66
|
*
|
|
66
|
-
* @param line The line of text to check
|
|
67
|
-
* @param file The file path where this line was found
|
|
68
|
-
* @param lineNumber The line number in the file
|
|
69
|
-
* @param omitValuesFromEnhancedScan Optional array of values to exclude from matching
|
|
70
|
-
* @returns Array of matches found in the line
|
|
71
67
|
*/
|
|
72
|
-
export declare function findLikelySecrets({
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
export declare function findLikelySecrets({ text, omitValuesFromEnhancedScan, }: {
|
|
69
|
+
/**
|
|
70
|
+
* Text to check
|
|
71
|
+
*/
|
|
72
|
+
text: string;
|
|
73
|
+
/**
|
|
74
|
+
* Optional array of values to exclude from matching
|
|
75
|
+
*/
|
|
76
76
|
omitValuesFromEnhancedScan?: unknown[];
|
|
77
|
-
}):
|
|
77
|
+
}): {
|
|
78
|
+
index: number;
|
|
79
|
+
prefix: string;
|
|
80
|
+
}[];
|
|
78
81
|
/**
|
|
79
82
|
* Given the env and base directory, find all file paths to scan. It will look at the
|
|
80
83
|
* env vars to decide if it should omit certain paths.
|
|
@@ -95,7 +98,7 @@ export declare function getFilePathsToScan({ env, base }: {
|
|
|
95
98
|
* @param scanArgs {ScanArgs} scan options
|
|
96
99
|
* @returns promise with all of the scan results, if any
|
|
97
100
|
*/
|
|
98
|
-
export declare function scanFilesForKeyValues({ env, keys, filePaths, base, enhancedScanning, omitValuesFromEnhancedScan, }: ScanArgs): Promise<ScanResults>;
|
|
101
|
+
export declare function scanFilesForKeyValues({ env, keys, filePaths, base, enhancedScanning, omitValuesFromEnhancedScan, useMinimalChunks, }: ScanArgs): Promise<ScanResults>;
|
|
99
102
|
/**
|
|
100
103
|
* ScanResults are all of the finds for all keys and their disparate locations. Scanning is
|
|
101
104
|
* async in streams so order can change a lot. Some matches are the result of an env var explictly being marked as secret,
|
|
@@ -21,7 +21,7 @@ export function isSecretsScanningEnabled(env) {
|
|
|
21
21
|
* @returns
|
|
22
22
|
*/
|
|
23
23
|
export function isEnhancedSecretsScanningEnabled(env) {
|
|
24
|
-
if (env.
|
|
24
|
+
if (env.SECRETS_SCAN_SMART_DETECTION_ENABLED === false || env.SECRETS_SCAN_SMART_DETECTION_ENABLED === 'false') {
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
27
|
return true;
|
|
@@ -37,7 +37,7 @@ export function getStringArrayFromEnvValue(env, envVarName) {
|
|
|
37
37
|
return omitKeys;
|
|
38
38
|
}
|
|
39
39
|
export function getOmitValuesFromEnhancedScanForEnhancedScanFromEnv(env) {
|
|
40
|
-
return getStringArrayFromEnvValue(env, '
|
|
40
|
+
return getStringArrayFromEnvValue(env, 'SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES');
|
|
41
41
|
}
|
|
42
42
|
function filterOmittedKeys(env, envKeys = []) {
|
|
43
43
|
const omitKeys = getStringArrayFromEnvValue(env, 'SECRETS_SCAN_OMIT_KEYS');
|
|
@@ -84,61 +84,78 @@ export function getSecretKeysToScanFor(env, secretKeys) {
|
|
|
84
84
|
const filteredSecretKeys = filterOmittedKeys(env, secretKeys);
|
|
85
85
|
return filteredSecretKeys.filter((key) => !isValueTrivial(env[key]));
|
|
86
86
|
}
|
|
87
|
+
const getShannonEntropy = (str) => {
|
|
88
|
+
const len = str.length;
|
|
89
|
+
if (len === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
const freqMap = {};
|
|
92
|
+
for (const char of str) {
|
|
93
|
+
freqMap[char] = (freqMap[char] || 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
let entropy = 0;
|
|
96
|
+
for (const char in freqMap) {
|
|
97
|
+
const p = freqMap[char] / len;
|
|
98
|
+
entropy -= p * Math.log2(p);
|
|
99
|
+
}
|
|
100
|
+
return entropy;
|
|
101
|
+
};
|
|
102
|
+
const HIGH_ENTROPY_THRESHOLD = 4.5;
|
|
103
|
+
const doesEntropyMeetThresholdForSecret = (str) => {
|
|
104
|
+
const entropy = getShannonEntropy(str);
|
|
105
|
+
return entropy >= HIGH_ENTROPY_THRESHOLD;
|
|
106
|
+
};
|
|
87
107
|
// Most prefixes are 4-5 chars, so requiring 12 chars after ensures a reasonable secret length
|
|
88
108
|
const MIN_CHARS_AFTER_PREFIX = 12;
|
|
89
109
|
// Escape special regex characters (like $, *, +, etc) in prefixes so they're treated as literal characters
|
|
90
110
|
const prefixMatchingRegex = LIKELY_SECRET_PREFIXES.map((p) => p.replace(/[$*+?.()|[\]{}]/g, '\\$&')).join('|');
|
|
91
111
|
// Build regex pattern for matching secrets with various delimiters and quotes:
|
|
92
|
-
// (?:["'
|
|
112
|
+
// (?:["'\`]|[=]) - match either quotes, or = at the start
|
|
93
113
|
// Named capturing groups:
|
|
94
114
|
// - <token>: captures the entire secret value including its prefix
|
|
95
115
|
// - <prefix>: captures just the prefix part (e.g. 'aws_', 'github_pat_')
|
|
96
116
|
// (?:${prefixMatchingRegex}) - non-capturing group containing our escaped prefixes (e.g. aws_|github_pat_|etc)
|
|
97
|
-
// [
|
|
98
|
-
// [
|
|
99
|
-
// (?:["'
|
|
117
|
+
// [a-zA-Z0-9-]{${MIN_CHARS_AFTER_PREFIX}} - match exactly MIN_CHARS_AFTER_PREFIX chars (alphanumeric or dash) after the prefix
|
|
118
|
+
// [a-zA-Z0-9-]*? - lazily match any additional chars (alphanumeric or dash)
|
|
119
|
+
// (?:["'\`]|$) - end with either quotes or end of line
|
|
100
120
|
// gi - global and case insensitive flags
|
|
101
121
|
// Note: Using the global flag (g) means this regex object maintains state between executions.
|
|
102
122
|
// We would need to reset lastIndex to 0 if we wanted to reuse it on the same string multiple times.
|
|
103
|
-
const likelySecretRegex = new RegExp(`(?:["'\`]
|
|
123
|
+
const likelySecretRegex = new RegExp(`(?:["'\`]|[=]) *(?<token>(?<prefix>${prefixMatchingRegex})[a-zA-Z0-9-]{${MIN_CHARS_AFTER_PREFIX}}[a-zA-Z0-9-]*?)(?:["'\`]|$)`, 'gi');
|
|
104
124
|
/**
|
|
105
|
-
* Checks a
|
|
125
|
+
* Checks a chunk of text for likely secrets based on known prefixes and patterns.
|
|
106
126
|
* The function works by:
|
|
107
|
-
* 1. Splitting the
|
|
127
|
+
* 1. Splitting the chunk into tokens using quotes, whitespace, equals signs, colons, and commas as delimiters
|
|
108
128
|
* 2. For each token, checking if it matches our secret pattern:
|
|
109
129
|
* - Must start (^) with one of our known prefixes (e.g. aws_, github_pat_, etc)
|
|
110
130
|
* - Must be followed by at least MIN_CHARS_AFTER_PREFIX non-whitespace characters
|
|
111
131
|
* - Must extend to the end ($) of the token
|
|
112
132
|
*
|
|
113
|
-
* For example, given the
|
|
133
|
+
* For example, given the chunk: secretKey='aws_123456789012345678'
|
|
114
134
|
* 1. It's split into tokens: ['secretKey', 'aws_123456789012345678']
|
|
115
135
|
* 2. Each token is checked against the regex pattern:
|
|
116
136
|
* - 'secretKey' doesn't match (doesn't start with a known prefix)
|
|
117
137
|
* - 'aws_123456789012345678' matches (starts with 'aws_' and has sufficient length)
|
|
118
138
|
*
|
|
119
|
-
* @param line The line of text to check
|
|
120
|
-
* @param file The file path where this line was found
|
|
121
|
-
* @param lineNumber The line number in the file
|
|
122
|
-
* @param omitValuesFromEnhancedScan Optional array of values to exclude from matching
|
|
123
|
-
* @returns Array of matches found in the line
|
|
124
139
|
*/
|
|
125
|
-
export function findLikelySecrets({
|
|
126
|
-
if (!
|
|
140
|
+
export function findLikelySecrets({ text, omitValuesFromEnhancedScan = [], }) {
|
|
141
|
+
if (!text)
|
|
127
142
|
return [];
|
|
128
143
|
const matches = [];
|
|
129
144
|
let match;
|
|
130
145
|
const allOmittedValues = [...omitValuesFromEnhancedScan, ...SAFE_LISTED_VALUES];
|
|
131
|
-
while ((match = likelySecretRegex.exec(
|
|
146
|
+
while ((match = likelySecretRegex.exec(text)) !== null) {
|
|
132
147
|
const token = match.groups?.token;
|
|
133
148
|
const prefix = match.groups?.prefix;
|
|
134
149
|
if (!token || !prefix || allOmittedValues.includes(token)) {
|
|
135
150
|
continue;
|
|
136
151
|
}
|
|
152
|
+
// Despite the prefix, the string does not look random enough to be convinced it's a secret
|
|
153
|
+
if (!doesEntropyMeetThresholdForSecret(token)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
137
156
|
matches.push({
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
key: prefix,
|
|
141
|
-
enhancedMatch: true,
|
|
157
|
+
prefix,
|
|
158
|
+
index: match.index,
|
|
142
159
|
});
|
|
143
160
|
}
|
|
144
161
|
return matches;
|
|
@@ -204,7 +221,7 @@ const omitPathMatches = (relativePath, omitPaths) => {
|
|
|
204
221
|
* @param scanArgs {ScanArgs} scan options
|
|
205
222
|
* @returns promise with all of the scan results, if any
|
|
206
223
|
*/
|
|
207
|
-
export async function scanFilesForKeyValues({ env, keys, filePaths, base, enhancedScanning, omitValuesFromEnhancedScan = [], }) {
|
|
224
|
+
export async function scanFilesForKeyValues({ env, keys, filePaths, base, enhancedScanning, omitValuesFromEnhancedScan = [], useMinimalChunks = false, }) {
|
|
208
225
|
const scanResults = {
|
|
209
226
|
matches: [],
|
|
210
227
|
scannedFilesCount: 0,
|
|
@@ -225,6 +242,7 @@ export async function scanFilesForKeyValues({ env, keys, filePaths, base, enhanc
|
|
|
225
242
|
}, {});
|
|
226
243
|
scanResults.scannedFilesCount = filePaths.length;
|
|
227
244
|
let settledPromises = [];
|
|
245
|
+
const searchStream = useMinimalChunks ? searchStreamMinimalChunks : searchStreamReadline;
|
|
228
246
|
// process the scanning in batches to not run into memory issues by
|
|
229
247
|
// processing all files at the same time.
|
|
230
248
|
while (filePaths.length > 0) {
|
|
@@ -241,7 +259,10 @@ export async function scanFilesForKeyValues({ env, keys, filePaths, base, enhanc
|
|
|
241
259
|
});
|
|
242
260
|
return scanResults;
|
|
243
261
|
}
|
|
244
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Search stream implementation using node:readline
|
|
264
|
+
*/
|
|
265
|
+
const searchStreamReadline = ({ basePath, file, keyValues, enhancedScanning, omitValuesFromEnhancedScan = [], }) => {
|
|
245
266
|
return new Promise((resolve, reject) => {
|
|
246
267
|
const filePath = path.resolve(basePath, file);
|
|
247
268
|
const inStream = createReadStream(filePath);
|
|
@@ -270,7 +291,12 @@ const searchStream = ({ basePath, file, keyValues, enhancedScanning, omitValuesF
|
|
|
270
291
|
lineNumber++;
|
|
271
292
|
if (typeof line === 'string') {
|
|
272
293
|
if (enhancedScanning) {
|
|
273
|
-
matches.push(...findLikelySecrets({ line,
|
|
294
|
+
matches.push(...findLikelySecrets({ text: line, omitValuesFromEnhancedScan }).map(({ prefix }) => ({
|
|
295
|
+
key: prefix,
|
|
296
|
+
file,
|
|
297
|
+
lineNumber,
|
|
298
|
+
enhancedMatch: true,
|
|
299
|
+
})));
|
|
274
300
|
}
|
|
275
301
|
if (maxMultiLineCount > 1) {
|
|
276
302
|
lines.push(line);
|
|
@@ -352,6 +378,184 @@ const searchStream = ({ basePath, file, keyValues, enhancedScanning, omitValuesF
|
|
|
352
378
|
});
|
|
353
379
|
});
|
|
354
380
|
};
|
|
381
|
+
/**
|
|
382
|
+
* Search stream implementation using just read stream that allows to buffer less content
|
|
383
|
+
*/
|
|
384
|
+
const searchStreamMinimalChunks = ({ basePath, file, keyValues, enhancedScanning, omitValuesFromEnhancedScan = [], }) => {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const matches = [];
|
|
387
|
+
const keyVals = [].concat(...Object.values(keyValues));
|
|
388
|
+
// determine longest value that we will search for - needed to determine minimal size of rolling buffer
|
|
389
|
+
const maxValLength = Math.max(0,
|
|
390
|
+
// explicit secrets
|
|
391
|
+
...keyVals.map((v) => v.length), ...(enhancedScanning
|
|
392
|
+
? [
|
|
393
|
+
// omitted likely secrets (after finding likely secret we check if it should be omitted, so we need to capture at least size of omitted values)
|
|
394
|
+
...omitValuesFromEnhancedScan.map((v) => (typeof v === 'string' ? v.length : 0)),
|
|
395
|
+
// minimum length needed to find likely secret
|
|
396
|
+
...LIKELY_SECRET_PREFIXES.map((v) => v.length + MIN_CHARS_AFTER_PREFIX),
|
|
397
|
+
]
|
|
398
|
+
: []));
|
|
399
|
+
if (maxValLength === 0) {
|
|
400
|
+
// no non-empty values to scan for
|
|
401
|
+
resolve(matches);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const filePath = path.resolve(basePath, file);
|
|
405
|
+
const inStream = createReadStream(filePath);
|
|
406
|
+
function getKeyForValue(val) {
|
|
407
|
+
let key = '';
|
|
408
|
+
for (const [secretKeyName, valuePermutations] of Object.entries(keyValues)) {
|
|
409
|
+
if (valuePermutations.includes(val)) {
|
|
410
|
+
key = secretKeyName;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return key;
|
|
414
|
+
}
|
|
415
|
+
let buffer = '';
|
|
416
|
+
let newLinesIndexesInCurrentBuffer = null;
|
|
417
|
+
function getCurrentBufferNewLineIndexes() {
|
|
418
|
+
if (newLinesIndexesInCurrentBuffer === null) {
|
|
419
|
+
newLinesIndexesInCurrentBuffer = [];
|
|
420
|
+
let newLineIndex = -1;
|
|
421
|
+
while ((newLineIndex = buffer.indexOf('\n', newLineIndex + 1)) !== -1) {
|
|
422
|
+
newLinesIndexesInCurrentBuffer.push(newLineIndex);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return newLinesIndexesInCurrentBuffer;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Amount of characters that were fully processed. Used to determine absolute position of current rolling buffer
|
|
429
|
+
* in the file.
|
|
430
|
+
*/
|
|
431
|
+
let processedCharacters = 0;
|
|
432
|
+
/**
|
|
433
|
+
* Amount of lines that were fully processed. Used to determine absolute line number of matches in current rolling buffer.
|
|
434
|
+
*/
|
|
435
|
+
let processedLines = 0;
|
|
436
|
+
/**
|
|
437
|
+
* Map keeping track of found secrets in current file. Used to prevent reporting same secret+position multiple times.
|
|
438
|
+
* Needed because rolling buffer might retain same secret in multiple passes.
|
|
439
|
+
*/
|
|
440
|
+
const foundIndexes = new Map();
|
|
441
|
+
/**
|
|
442
|
+
* We report given secret at most once per line, so we keep track lines we already reported for given secret.
|
|
443
|
+
*/
|
|
444
|
+
const foundLines = new Map();
|
|
445
|
+
/**
|
|
446
|
+
* Calculate absolute line number in a file for given match in the current rolling buffer.
|
|
447
|
+
*/
|
|
448
|
+
function getLineNumberForMatchInTheBuffer({ indexInBuffer, key }) {
|
|
449
|
+
const absolutePositionInFile = processedCharacters + indexInBuffer;
|
|
450
|
+
// check if we already handled match for given key in this position
|
|
451
|
+
let foundIndexesForKey = foundIndexes.get(key);
|
|
452
|
+
if (!foundIndexesForKey?.has(absolutePositionInFile)) {
|
|
453
|
+
// ensure we track match for this key and position to not report it again in future passes
|
|
454
|
+
if (!foundIndexesForKey) {
|
|
455
|
+
foundIndexesForKey = new Set();
|
|
456
|
+
foundIndexes.set(key, foundIndexesForKey);
|
|
457
|
+
}
|
|
458
|
+
foundIndexesForKey.add(absolutePositionInFile);
|
|
459
|
+
// calculate line number based on amount of fully processed lines and position of line breaks in current buffer
|
|
460
|
+
let lineNumber = processedLines + 1;
|
|
461
|
+
for (const newLineIndex of getCurrentBufferNewLineIndexes()) {
|
|
462
|
+
if (indexInBuffer > newLineIndex) {
|
|
463
|
+
lineNumber++;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// check if we already handled match for given key in this line
|
|
470
|
+
let foundLinesForKey = foundLines.get(key);
|
|
471
|
+
if (!foundLinesForKey?.has(lineNumber)) {
|
|
472
|
+
if (!foundLinesForKey) {
|
|
473
|
+
foundLinesForKey = new Set();
|
|
474
|
+
foundLines.set(key, foundLinesForKey);
|
|
475
|
+
}
|
|
476
|
+
foundLinesForKey.add(lineNumber);
|
|
477
|
+
// only report line number if we didn't report it yet for this key
|
|
478
|
+
return lineNumber;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function processBuffer() {
|
|
483
|
+
for (const valVariant of keyVals) {
|
|
484
|
+
let indexInBuffer = -1;
|
|
485
|
+
while ((indexInBuffer = buffer.indexOf(valVariant, indexInBuffer + 1)) !== -1) {
|
|
486
|
+
const key = getKeyForValue(valVariant);
|
|
487
|
+
const lineNumber = getLineNumberForMatchInTheBuffer({
|
|
488
|
+
indexInBuffer,
|
|
489
|
+
key,
|
|
490
|
+
});
|
|
491
|
+
if (typeof lineNumber === 'number') {
|
|
492
|
+
matches.push({
|
|
493
|
+
file,
|
|
494
|
+
lineNumber,
|
|
495
|
+
key,
|
|
496
|
+
enhancedMatch: false,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (enhancedScanning) {
|
|
502
|
+
const likelySecrets = findLikelySecrets({ text: buffer, omitValuesFromEnhancedScan });
|
|
503
|
+
for (const { index, prefix } of likelySecrets) {
|
|
504
|
+
const lineNumber = getLineNumberForMatchInTheBuffer({
|
|
505
|
+
indexInBuffer: index,
|
|
506
|
+
key: prefix,
|
|
507
|
+
});
|
|
508
|
+
if (typeof lineNumber === 'number') {
|
|
509
|
+
matches.push({
|
|
510
|
+
file,
|
|
511
|
+
lineNumber,
|
|
512
|
+
key: prefix,
|
|
513
|
+
enhancedMatch: true,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
inStream.on('data', function (chunk) {
|
|
520
|
+
buffer += chunk.toString();
|
|
521
|
+
// reset new line positions in current buffer
|
|
522
|
+
newLinesIndexesInCurrentBuffer = null;
|
|
523
|
+
if (buffer.length > maxValLength) {
|
|
524
|
+
// only process if buffer is large enough to contain longest secret, if final chunk isn't large enough
|
|
525
|
+
// it will be processed in `close` event handler
|
|
526
|
+
processBuffer();
|
|
527
|
+
// we will keep maxValLength characters in the buffer, surplus of characters at this point is fully processed
|
|
528
|
+
const charactersInBufferThatWereFullyProcessed = buffer.length - maxValLength;
|
|
529
|
+
processedCharacters += charactersInBufferThatWereFullyProcessed;
|
|
530
|
+
// advance processed lines
|
|
531
|
+
for (const newLineIndex of getCurrentBufferNewLineIndexes()) {
|
|
532
|
+
if (newLineIndex < charactersInBufferThatWereFullyProcessed) {
|
|
533
|
+
processedLines++;
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Keep the last part of the buffer to handle split values across chunks
|
|
540
|
+
buffer = buffer.slice(charactersInBufferThatWereFullyProcessed);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
inStream.on('error', function (error) {
|
|
544
|
+
if (error?.code === 'EISDIR') {
|
|
545
|
+
// file path is a directory - do nothing
|
|
546
|
+
resolve(matches);
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
reject(error);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
inStream.on('close', function () {
|
|
553
|
+
// process any remaining buffer content
|
|
554
|
+
processBuffer();
|
|
555
|
+
resolve(matches);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
};
|
|
355
559
|
/**
|
|
356
560
|
* ScanResults are all of the finds for all keys and their disparate locations. Scanning is
|
|
357
561
|
* async in streams so order can change a lot. Some matches are the result of an env var explictly being marked as secret,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netlify/build",
|
|
3
|
-
"version": "33.
|
|
3
|
+
"version": "33.4.0",
|
|
4
4
|
"description": "Netlify build module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./lib/index.js",
|
|
@@ -67,17 +67,17 @@
|
|
|
67
67
|
"license": "MIT",
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@bugsnag/js": "^8.0.0",
|
|
70
|
-
"@netlify/blobs": "^
|
|
71
|
-
"@netlify/cache-utils": "^6.0.
|
|
72
|
-
"@netlify/config": "^23.0.
|
|
73
|
-
"@netlify/edge-bundler": "14.0.
|
|
70
|
+
"@netlify/blobs": "^9.1.3",
|
|
71
|
+
"@netlify/cache-utils": "^6.0.3",
|
|
72
|
+
"@netlify/config": "^23.0.8",
|
|
73
|
+
"@netlify/edge-bundler": "14.0.5",
|
|
74
74
|
"@netlify/framework-info": "^10.0.4",
|
|
75
|
-
"@netlify/functions-utils": "^6.0.
|
|
76
|
-
"@netlify/git-utils": "^6.0.
|
|
77
|
-
"@netlify/opentelemetry-utils": "^2.0.
|
|
75
|
+
"@netlify/functions-utils": "^6.0.7",
|
|
76
|
+
"@netlify/git-utils": "^6.0.2",
|
|
77
|
+
"@netlify/opentelemetry-utils": "^2.0.1",
|
|
78
78
|
"@netlify/plugins-list": "^6.80.0",
|
|
79
|
-
"@netlify/run-utils": "^6.0.
|
|
80
|
-
"@netlify/zip-it-and-ship-it": "12.1.
|
|
79
|
+
"@netlify/run-utils": "^6.0.2",
|
|
80
|
+
"@netlify/zip-it-and-ship-it": "12.1.1",
|
|
81
81
|
"@sindresorhus/slugify": "^2.0.0",
|
|
82
82
|
"ansi-escapes": "^7.0.0",
|
|
83
83
|
"chalk": "^5.0.0",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"fdir": "^6.0.1",
|
|
87
87
|
"figures": "^6.0.0",
|
|
88
88
|
"filter-obj": "^6.0.0",
|
|
89
|
-
"got": "^
|
|
89
|
+
"got": "^13.0.0",
|
|
90
90
|
"hot-shots": "10.2.1",
|
|
91
91
|
"indent-string": "^5.0.0",
|
|
92
92
|
"is-plain-obj": "^4.0.0",
|
|
@@ -125,20 +125,20 @@
|
|
|
125
125
|
"yargs": "^17.6.0"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@netlify/nock-udp": "^5.0.
|
|
128
|
+
"@netlify/nock-udp": "^5.0.1",
|
|
129
129
|
"@opentelemetry/api": "~1.8.0",
|
|
130
130
|
"@opentelemetry/sdk-trace-base": "~1.24.0",
|
|
131
|
-
"@types/node": "^
|
|
131
|
+
"@types/node": "^18.0.0",
|
|
132
132
|
"atob": "^2.1.2",
|
|
133
133
|
"ava": "^5.0.0",
|
|
134
134
|
"c8": "^10.0.0",
|
|
135
135
|
"copyfiles": "^2.4.1",
|
|
136
136
|
"cpy": "^11.0.0",
|
|
137
|
-
"get-node": "^
|
|
137
|
+
"get-node": "^14.2.1",
|
|
138
138
|
"get-port": "^7.0.0",
|
|
139
139
|
"has-ansi": "^6.0.0",
|
|
140
140
|
"moize": "^6.0.0",
|
|
141
|
-
"npm-run-all2": "^
|
|
141
|
+
"npm-run-all2": "^6.0.0",
|
|
142
142
|
"process-exists": "^5.0.0",
|
|
143
143
|
"sinon": "^20.0.0",
|
|
144
144
|
"tmp-promise": "^3.0.2",
|
|
@@ -158,5 +158,5 @@
|
|
|
158
158
|
"engines": {
|
|
159
159
|
"node": ">=18.14.0"
|
|
160
160
|
},
|
|
161
|
-
"gitHead": "
|
|
161
|
+
"gitHead": "5289c05c1991824b24e3a8c38c8457bdc5534046"
|
|
162
162
|
}
|