Skip to content

Commit

Permalink
Inject secrets into templates command (#158)
Browse files Browse the repository at this point in the history
Fix #153
  • Loading branch information
Mikescops authored Aug 9, 2023
1 parent e7cab0a commit 8252cb3
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 28 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ bundle
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# Testing files
template.txt
output.txt
1 change: 1 addition & 0 deletions src/command-handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './configure';
export * from './devices';
export * from './inject';
export * from './logout';
export * from './passwords';
export * from './read';
Expand Down
83 changes: 83 additions & 0 deletions src/command-handlers/inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import fs from 'fs';
import { getVaultSecret, initVaultSecrets } from '../modules/database';

interface InjectOpts {
in: string;
out: string;
}

const parse = (template: string) => {
let result = /{{(.*?)}}/g.exec(template);
const arr = [];
let firstPos;

while (result) {
firstPos = result.index;
if (firstPos !== 0) {
arr.push(template.substring(0, firstPos));
template = template.slice(firstPos);
}

arr.push(result[0]);
template = template.slice(result[0].length);
result = /{{(.*?)}}/g.exec(template);
}

if (template) {
arr.push(template);
}

return arr;
};

const compile = (template: string) => {
const ast = parse(template);
let fnStr = '';

ast.map((t) => {
if (t.startsWith('{{') && t.endsWith('}}')) {
const key = t.split(/{{|}}/).filter(Boolean)[0].trim();
if (key.startsWith('dl://')) {
fnStr += getVaultSecret(key);
return;
}
}
fnStr += t;
});

return fnStr;
};

export const runInject = async (options: InjectOpts) => {
const { in: inputFilePath, out: outputFilePath } = options;

await initVaultSecrets();

if (inputFilePath) {
const input = fs.readFileSync(inputFilePath, 'utf8');

outputContent(compile(input), outputFilePath);
return;
}

let stdin = '';

process.stdin.on('readable', () => {
const chunk = process.stdin.read() as string;
if (chunk !== null) {
stdin += chunk;
}
});

process.stdin.on('end', () => {
outputContent(compile(stdin.trim()), outputFilePath);
});
};

const outputContent = (output: string, outputFilePath?: string) => {
if (outputFilePath) {
fs.writeFileSync(outputFilePath, output);
} else {
console.log(output);
}
};
25 changes: 2 additions & 23 deletions src/command-handlers/read.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { decryptTransactions } from '../modules/crypto';
import { connectAndPrepare } from '../modules/database';
import { connectAndPrepare, findVaultSecret } from '../modules/database';
import { AuthentifiantTransactionContent, BackupEditTransaction, SecureNoteTransactionContent } from '../types';
import { beautifySecrets, parsePath } from '../utils';

Expand Down Expand Up @@ -48,26 +48,5 @@ export const runRead = async (path: string) => {

const secretsDecrypted = beautifySecrets({ credentials: decryptedCredentials, notes: decryptedNotes });

if (parsedPath.title) {
secretsDecrypted.credentials = secretsDecrypted.credentials.filter(
(credential) => credential.title === parsedPath.title
);
secretsDecrypted.notes = secretsDecrypted.notes.filter((note) => note.title === parsedPath.title);
}

if (secretsDecrypted.credentials.length === 0 && secretsDecrypted.notes.length === 0) {
throw new Error('No matching secret found');
}

const secretToRender: Record<string, any> =
secretsDecrypted.credentials.length > 0 ? secretsDecrypted.credentials[0] : secretsDecrypted.notes[0];

if (parsedPath.field) {
if (!secretToRender[parsedPath.field]) {
throw new Error('No matching field found');
}
return console.log(secretToRender[parsedPath.field]);
}

console.log(JSON.stringify(secretToRender, null, 4));
console.log(findVaultSecret(secretsDecrypted, parsedPath));
};
10 changes: 8 additions & 2 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command, Option } from 'commander';
import { devicesCommands } from './devices';
import { teamCommands } from './team';
import { configureCommands } from './configure';
import { runSync, runOtp, runPassword, runSecureNote, runLogout, runRead } from '../command-handlers';
import { runSync, runOtp, runPassword, runSecureNote, runLogout, runRead, runInject } from '../command-handlers';

export const rootCommands = (params: { program: Command }) => {
const { program } = params;
Expand All @@ -15,11 +15,17 @@ export const rootCommands = (params: { program: Command }) => {

program
.command('read')
.alias('r')
.description('Retrieve a credential from the local vault via its path')
.argument('<path>', 'Path to the credential (dl://<title>/<field> or dl://<id>/<field>)')
.action(runRead);

program
.command('inject')
.description('Inject secrets into a templated string or file (uses stdin and stdout by default)')
.option('-i, --in <input_file>', 'Input file of a template to inject the credential into')
.option('-o, --out <output_file>', 'Output file to write the injected template to')
.action(runInject);

program
.command('password')
.alias('p')
Expand Down
1 change: 1 addition & 0 deletions src/modules/database/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './connect';
export * from './connectAndPrepare';
export * from './reset';
export * from './vaultSecrets';
79 changes: 79 additions & 0 deletions src/modules/database/vaultSecrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { connectAndPrepare } from './connectAndPrepare';
import {
VaultSecrets,
BackupEditTransaction,
AuthentifiantTransactionContent,
SecureNoteTransactionContent,
ParsedPath,
} from '../../types';
import { beautifySecrets, parsePath } from '../../utils';
import { decryptTransactions } from '../crypto';

let vaultSecrets: VaultSecrets | undefined = undefined;

export const initVaultSecrets = async () => {
if (vaultSecrets) {
return;
}

const { secrets, db } = await connectAndPrepare({});

const transactions = db
.prepare(
`SELECT *
FROM transactions
WHERE login = ?
AND action = 'BACKUP_EDIT'
AND (type = 'AUTHENTIFIANT' OR type = 'SECURENOTE')
`
)
.bind(secrets.login)
.all() as BackupEditTransaction[];

const credentials = transactions.filter((transaction) => transaction.type === 'AUTHENTIFIANT');
const notes = transactions.filter((transaction) => transaction.type === 'SECURENOTE');

const decryptedCredentials = await decryptTransactions<AuthentifiantTransactionContent>(credentials, secrets);
const decryptedNotes = await decryptTransactions<SecureNoteTransactionContent>(notes, secrets);

vaultSecrets = beautifySecrets({ credentials: decryptedCredentials, notes: decryptedNotes });
};

export const getVaultSecret = (path: string): string => {
if (!vaultSecrets) {
throw new Error('Vault secrets not initialized');
}

const parsedPath = parsePath(path);

return findVaultSecret(vaultSecrets, parsedPath);
};

export const findVaultSecret = (vaultSecrets: VaultSecrets, parsedPath: ParsedPath): string => {
if (parsedPath.title) {
vaultSecrets.credentials = vaultSecrets.credentials.filter(
(credential) => credential.title === parsedPath.title
);
vaultSecrets.notes = vaultSecrets.notes.filter((note) => note.title === parsedPath.title);
}

if (vaultSecrets.credentials.length === 0 && vaultSecrets.notes.length === 0) {
throw new Error(`No matching secret found for "${parsedPath.secretId ?? parsedPath.title ?? ''}"`);
}

const secretToRender: Record<string, any> =
vaultSecrets.credentials.length > 0 ? vaultSecrets.credentials[0] : vaultSecrets.notes[0];

if (parsedPath.field) {
if (!secretToRender[parsedPath.field]) {
throw new Error(
`No matching field found for "${parsedPath.field}" in "${
parsedPath.secretId ?? parsedPath.title ?? ''
}"`
);
}
return String(secretToRender[parsedPath.field]);
}

return JSON.stringify(secretToRender);
};
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,15 @@ export class PrintableVaultNote {
}
}

export interface VaultSecrets {
credentials: VaultCredential[];
notes: VaultNote[];
}

export type SupportedAuthenticationMethod = 'email_token' | 'totp' | 'duo_push' | 'dashlane_authenticator';

export interface ParsedPath {
secretId?: string;
title?: string;
field?: string;
}
7 changes: 4 additions & 3 deletions src/utils/secretPath.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { isUuid } from './strings';
import { ParsedPath } from '../types';

/**
* Function to parse a custom Dashlane path and return the query parameters for the vault lookup
* First we check if the path is a valid Dashlane path, should start with dl://
* Then we check if the path is a valid Dashlane vault id, should be a 32 character UUID string (ie: 11111111-1111-1111-1111-111111111111)
* Otherwise, we assume the path is a valid Dashlane title, should be a string
* Finally, we check if the next chunk of the path is a valid Dashlane field, should be a string
* Otherwise, we assume the path is a valid Dashlane title
* Finally, we check if the next chunk of the path is a valid Dashlane field
* @param path
*/
export const parsePath = (path: string): { secretId?: string; title?: string; field?: string } => {
export const parsePath = (path: string): ParsedPath => {
if (!path.startsWith('dl://')) {
throw new Error('Invalid Dashlane path');
}
Expand Down

0 comments on commit 8252cb3

Please sign in to comment.