Skip to content

Commit af0b44d

Browse files
committed
feat(functions:config:export): enhance export with security and migration features
- Add secret detection warnings with user confirmation - Enhance .env comments with type hints and secret indicators - Add --dry-run flag to preview exports without writing files - Add migration hints for secrets, booleans, and numbers - Add value validation warnings for edge cases - Add comprehensive export summary with next steps - Improve .env file formatting with aligned comments
1 parent 6fed729 commit af0b44d

File tree

1 file changed

+219
-6
lines changed

1 file changed

+219
-6
lines changed

src/commands/functions-config-export.ts

Lines changed: 219 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,160 @@ function fromEntries<V>(itr: Iterable<[string, V]>): Record<string, V> {
446446
return obj;
447447
}
448448

449+
function isLikelySecret(key: string): boolean {
450+
const secretPatterns = [
451+
/\bapi[_-]?key\b/i,
452+
/\bsecret\b/i,
453+
/\bpassw(ord|d)\b/i,
454+
/\bprivate[_-]?key\b/i,
455+
/_token$/i,
456+
/_auth$/i,
457+
/_credential$/i,
458+
/\bkey\b/i,
459+
/\btoken\b/i,
460+
/\bauth\b/i,
461+
/\bcredential\b/i
462+
];
463+
464+
return secretPatterns.some(pattern => pattern.test(key));
465+
}
466+
467+
function getEnhancedComment(origKey: string, value: string): string {
468+
const parts = [`from ${origKey}`];
469+
470+
// Add type hint
471+
if (value === "true" || value === "false") {
472+
parts.push("[boolean]");
473+
} else if (!isNaN(Number(value)) && value !== "") {
474+
parts.push("[number]");
475+
} else if (value.includes(",")) {
476+
parts.push("[possible list]");
477+
}
478+
479+
// Add secret warning
480+
if (isLikelySecret(origKey)) {
481+
parts.push("⚠️ LIKELY SECRET");
482+
}
483+
484+
return parts.length > 1 ? ` # ${parts.join(" ")}` : ` # ${parts[0]}`;
485+
}
486+
487+
function escape(s: string): string {
488+
// Escape newlines, tabs, backslashes and quotes
489+
return s.replace(/[\n\r\t\v\\"']/g, (ch) => {
490+
const escapeMap: Record<string, string> = {
491+
"\n": "\\n",
492+
"\r": "\\r",
493+
"\t": "\\t",
494+
"\v": "\\v",
495+
"\\": "\\\\",
496+
'"': '\\"',
497+
"'": "\\'",
498+
};
499+
return escapeMap[ch];
500+
});
501+
}
502+
503+
function enhancedToDotenvFormat(envs: configExport.EnvMap[], header = ""): string {
504+
const lines = envs.map(({ newKey, value, origKey }) => {
505+
const comment = getEnhancedComment(origKey, value);
506+
return `${newKey}="${escape(value)}"${comment}`;
507+
});
508+
509+
// Calculate max line length for alignment
510+
const maxLineLen = Math.max(...lines.map(l => l.indexOf(" #")));
511+
const alignedLines = lines.map(line => {
512+
const commentIndex = line.indexOf(" #");
513+
const padding = " ".repeat(Math.max(0, maxLineLen - commentIndex));
514+
return line.replace(" #", padding + " #");
515+
});
516+
517+
return `${header}\n${alignedLines.join('\n')}`;
518+
}
519+
520+
function addMigrationHints(envs: configExport.EnvMap[]): string {
521+
const hints: string[] = [];
522+
523+
const secrets = envs.filter(e => isLikelySecret(e.origKey));
524+
const booleans = envs.filter(e => e.value === "true" || e.value === "false");
525+
const numbers = envs.filter(e => !isNaN(Number(e.value)) && e.value !== "");
526+
527+
if (secrets.length > 0) {
528+
hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected.
529+
# Consider using defineSecret() for: ${secrets.map(s => s.newKey).join(", ")}
530+
# Run: firebase functions:secrets:set ${secrets[0].newKey}\n`);
531+
}
532+
533+
if (booleans.length > 0) {
534+
hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected.
535+
# Consider using defineBoolean() for: ${booleans.map(b => b.newKey).join(", ")}\n`);
536+
}
537+
538+
if (numbers.length > 0) {
539+
hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected.
540+
# Consider using defineInt() for: ${numbers.map(n => n.newKey).join(", ")}\n`);
541+
}
542+
543+
if (hints.length > 0) {
544+
hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`);
545+
}
546+
547+
return hints.join('\n');
548+
}
549+
550+
function validateConfigValues(pInfos: configExport.ProjectConfigInfo[]): string[] {
551+
const warnings: string[] = [];
552+
553+
for (const pInfo of pInfos) {
554+
if (!pInfo.envs) continue;
555+
556+
for (const env of pInfo.envs) {
557+
// Check for multiline values
558+
if (env.value.includes('\n')) {
559+
warnings.push(`${env.origKey}: Contains newlines (will be escaped)`);
560+
}
561+
562+
// Check for very long values
563+
if (env.value.length > 1000) {
564+
warnings.push(`${env.origKey}: Very long value (${env.value.length} chars)`);
565+
}
566+
567+
// Check for empty values
568+
if (env.value === '') {
569+
warnings.push(`${env.origKey}: Empty value`);
570+
}
571+
}
572+
}
573+
574+
return warnings;
575+
}
576+
577+
function showExportSummary(pInfos: configExport.ProjectConfigInfo[], filesToWrite: Record<string, string>): void {
578+
const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0);
579+
const filesCreated = Object.keys(filesToWrite).length;
580+
581+
logger.info("\n📊 Export Summary:");
582+
logger.info(` ✓ ${totalConfigs} config values exported`);
583+
logger.info(` ✓ ${filesCreated} files created`);
584+
585+
const secrets = pInfos.flatMap(p =>
586+
(p.envs || []).filter(e => isLikelySecret(e.origKey))
587+
);
588+
589+
if (secrets.length > 0) {
590+
logger.info(` ⚠️ ${secrets.length} potential secrets exported`);
591+
logger.info(`\n💡 Next steps:`);
592+
logger.info(` 1. Review .env files for sensitive values`);
593+
logger.info(` 2. Move secrets to Firebase: firebase functions:secrets:set`);
594+
logger.info(` 3. Update your code to use the params API`);
595+
logger.info(` 4. Run 'firebase functions:config:export --prompt' for migration help`);
596+
}
597+
}
598+
449599
export const command = new Command("functions:config:export")
450600
.description("export environment config as environment variables in dotenv format (or generate AI migration prompt with --prompt)")
451601
.option("--prompt", "Generate an AI migration prompt instead of exporting to .env files")
602+
.option("--dry-run", "Preview the export without writing files")
452603
.before(requirePermissions, [
453604
"runtimeconfig.configs.list",
454605
"runtimeconfig.configs.get",
@@ -526,16 +677,78 @@ export const command = new Command("functions:config:export")
526677
attempts += 1;
527678
}
528679

680+
// Check for secrets and warn user
681+
const secretsFound: string[] = [];
682+
for (const pInfo of pInfos) {
683+
if (pInfo.envs) {
684+
for (const env of pInfo.envs) {
685+
if (isLikelySecret(env.origKey)) {
686+
secretsFound.push(`${env.origKey}${env.newKey}`);
687+
}
688+
}
689+
}
690+
}
691+
692+
if (secretsFound.length > 0 && !options.dryRun) {
693+
logWarning(
694+
"⚠️ The following configs appear to be secrets and will be exported to .env files:\n" +
695+
secretsFound.map(s => ` - ${s}`).join('\n') +
696+
"\n\nConsider using Firebase Functions secrets instead: firebase functions:secrets:set"
697+
);
698+
699+
const proceed = await confirm({
700+
message: "Continue exporting these potentially sensitive values?",
701+
default: false
702+
});
703+
704+
if (!proceed) {
705+
throw new FirebaseError("Export cancelled by user");
706+
}
707+
}
708+
709+
// Validate config values and show warnings
710+
const valueWarnings = validateConfigValues(pInfos);
711+
if (valueWarnings.length > 0) {
712+
logWarning("⚠️ Value warnings:\n" + valueWarnings.map(w => ` - ${w}`).join('\n'));
713+
}
714+
529715
const header = `# Exported firebase functions:config:export command on ${new Date().toLocaleDateString()}`;
530-
const dotEnvs = pInfos.map((pInfo) => configExport.toDotenvFormat(pInfo.envs!, header));
531-
const filenames = pInfos.map(configExport.generateDotenvFilename);
532-
const filesToWrite = fromEntries(zip(filenames, dotEnvs));
716+
717+
// Generate enhanced .env files with migration hints
718+
const filesToWrite: Record<string, string> = {};
719+
720+
for (const pInfo of pInfos) {
721+
if (!pInfo.envs || pInfo.envs.length === 0) continue;
722+
723+
const filename = configExport.generateDotenvFilename(pInfo);
724+
const migrationHints = addMigrationHints(pInfo.envs);
725+
const envContent = enhancedToDotenvFormat(pInfo.envs, header);
726+
727+
filesToWrite[filename] = migrationHints ? `${header}\n${migrationHints}\n${envContent}` : envContent;
728+
}
729+
730+
// Add default files
533731
filesToWrite[".env.local"] =
534732
`${header}\n# .env.local file contains environment variables for the Functions Emulator.\n`;
535733
filesToWrite[".env"] =
536-
`${header}# .env file contains environment variables that applies to all projects.\n`;
734+
`${header}\n# .env file contains environment variables that applies to all projects.\n`;
537735

538-
for (const [filename, content] of Object.entries(filesToWrite)) {
539-
await options.config.askWriteProjectFile(path.join(functionsDir, filename), content);
736+
if (options.dryRun) {
737+
logger.info("🔍 DRY RUN MODE - No files will be written\n");
738+
739+
for (const [filename, content] of Object.entries(filesToWrite)) {
740+
console.log(clc.bold(clc.cyan(`=== ${filename} ===`)));
741+
console.log(content);
742+
console.log();
743+
}
744+
745+
logger.info("✅ Dry run complete. Use without --dry-run to write files.");
746+
} else {
747+
for (const [filename, content] of Object.entries(filesToWrite)) {
748+
await options.config.askWriteProjectFile(path.join(functionsDir, filename), content);
749+
}
750+
751+
// Show export summary
752+
showExportSummary(pInfos, filesToWrite);
540753
}
541754
});

0 commit comments

Comments
 (0)