@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.
@@ -3,4 +3,5 @@ export function getNewEnvChanges(envBefore: any, netlifyConfig: any, netlifyConf
3
3
  };
4
4
  export function setEnvChanges(envChanges: any, currentEnv?: NodeJS.ProcessEnv): {
5
5
  [key: string]: string | undefined;
6
+ TZ?: string;
6
7
  };
@@ -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
- // Likely secret matches from enhanced scan
117
- enhancedSecretMatchesKeys.forEach((key, index) => {
118
- logError(logs, `${index === 0 && secretMatchesKeys.length ? '\n' : ''}"${key}***" detected as a likely secret:`);
119
- enhancedSecretMatches[key]
120
- .sort((a, b) => {
121
- return a.file > b.file ? 0 : 1;
122
- })
123
- .forEach(({ lineNumber, file }) => {
124
- logError(logs, `found value at line ${lineNumber} in ${file}`, { indent: true });
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
- if (enhancedSecretMatchesKeys.length) {
128
- logError(logs, `\nTo prevent exposing secrets, the build will fail until these likely secret values are not found in build output or repo files.`);
129
- logError(logs, `\nIf these are expected, use ENHANCED_SECRETS_SCAN_OMIT_VALUES, or ENHANCED_SECRETS_SCAN_ENABLED to prevent detecting.`);
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
- const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.length);
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
- const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.length);
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
- if (enhancedSecretScan && !enhancedScanningEnabledInEnv) {
26
- logSecretsScanSkipMessage(logs, 'Enhanced secrets detection disabled via ENHANCED_SECRETS_SCAN_ENABLED flag set to false.');
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 (enhancedSecretScan &&
29
- enhancedScanningEnabledInEnv &&
30
- envVars['ENHANCED_SECRETS_SCAN_OMIT_VALUES'] !== undefined) {
31
- log(logs, `ENHANCED_SECRETS_SCAN_OMIT_VALUES override option set to: ${envVars['ENHANCED_SECRETS_SCAN_OMIT_VALUES']}\n`);
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 (keysToSearchFor.length === 0 && !enhancedSecretScan) {
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: enhancedSecretScan && enhancedScanningEnabledInEnv,
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: enhancedSecretScan && enhancedScanningEnabledInEnv,
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 || scanResults.matches.length === 0) {
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 line of text for likely secrets based on known prefixes and patterns.
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 line into tokens using quotes, whitespace, equals signs, colons, and commas as delimiters
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 line: secretKey='aws_123456789012345678'
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({ line, file, lineNumber, omitValuesFromEnhancedScan, }: {
73
- line: string;
74
- file: string;
75
- lineNumber: number;
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
- }): MatchResult[];
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.ENHANCED_SECRETS_SCAN_ENABLED === false || env.ENHANCED_SECRETS_SCAN_ENABLED === 'false') {
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, 'ENHANCED_SECRETS_SCAN_OMIT_VALUES');
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
- // (?:["'`]|^|[=:,]) - match either quotes, start of line, or delimiters (=:,) at the start
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
- // [^ "'`=:,]{${MIN_CHARS_AFTER_PREFIX}} - match exactly MIN_CHARS_AFTER_PREFIX chars after the prefix
98
- // [^ "'`=:,]*? - lazily match any additional chars that aren't quotes/delimiters
99
- // (?:["'`]|[ =:,]|$) - end with either quotes, delimiters, whitespace, or end of line
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(`(?:["'\`]|^|[=:,]) *(?<token>(?<prefix>${prefixMatchingRegex})[^ "'\`=:,]{${MIN_CHARS_AFTER_PREFIX}}[^ "'\`=:,]*?)(?:["'\`]|[ =:,]|$)`, 'gi');
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 line of text for likely secrets based on known prefixes and patterns.
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 line into tokens using quotes, whitespace, equals signs, colons, and commas as delimiters
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 line: secretKey='aws_123456789012345678'
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({ line, file, lineNumber, omitValuesFromEnhancedScan = [], }) {
126
- if (!line)
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(line)) !== null) {
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
- file,
139
- lineNumber,
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
- const searchStream = ({ basePath, file, keyValues, enhancedScanning, omitValuesFromEnhancedScan = [], }) => {
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, file, lineNumber, omitValuesFromEnhancedScan }));
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.2.1",
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": "^8.2.0",
71
- "@netlify/cache-utils": "^6.0.2",
72
- "@netlify/config": "^23.0.7",
73
- "@netlify/edge-bundler": "14.0.4",
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.6",
76
- "@netlify/git-utils": "^6.0.1",
77
- "@netlify/opentelemetry-utils": "^2.0.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.1",
80
- "@netlify/zip-it-and-ship-it": "12.1.0",
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": "^12.0.0",
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.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": "^14.18.53",
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": "^12.0.0",
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": "^5.0.0",
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": "f85f7e9a41f2c1698a320402d09072ee04f4dc6d"
161
+ "gitHead": "5289c05c1991824b24e3a8c38c8457bdc5534046"
162
162
  }