Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/scan-explicit-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"varlock": minor
---

`varlock scan` now accepts optional positional path/glob arguments to scan specific files, directories, or glob patterns instead of the whole repo. This is useful for scanning build output folders (e.g. `dist`, `.next`) to ensure no secrets leaked into what will be published.

```sh
varlock scan ./dist # Scan a specific build output directory
varlock scan ./dist ./public # Scan multiple directories
varlock scan './dist/**/*.js' # Scan files matching a glob pattern
```

When explicit paths are provided, git-aware filtering (`--staged`, `--include-ignored`) is bypassed, and build-output directories that are normally skipped (such as `dist`, `.next`, `build`) are scanned without restriction.
Original file line number Diff line number Diff line change
Expand Up @@ -216,15 +216,18 @@ sh -c 'echo $(varlock printenv MY_VAR)' # ✅ embed in a larger command

Scans your project files for sensitive config values that should not appear in plaintext. Loads your varlock config, resolves all `@sensitive` values, then checks files for any occurrences of those values.

This is especially useful as a **pre-commit git hook** to prevent accidentally committing secrets into version control.
This is especially useful as a **pre-commit git hook** to prevent accidentally committing secrets into version control, and for **scanning build output** to ensure no secrets leaked into files that will be published or deployed.

```bash
varlock scan [options]
varlock scan [paths...] [options]
```

**Positional arguments:**
- `[paths...]`: Optional list of file paths, directories, or glob patterns to scan. When provided, only these targets are scanned — git filtering (`--staged`, `--include-ignored`) is bypassed and build-output directories that are normally skipped (such as `dist`, `.next`, `build`) are included.

**Options:**
- `--staged`: Only scan staged git files
- `--include-ignored`: Include git-ignored files in the scan (by default, gitignored files are skipped)
- `--staged`: Only scan staged git files (ignored when explicit paths are provided)
- `--include-ignored`: Include git-ignored files in the scan (ignored when explicit paths are provided)
- `--install-hook`: Set up `varlock scan` as a git pre-commit hook
- `--path` / `-p`: Path to a specific `.env` file or directory to use as the schema entry point. Can be specified multiple times to load from multiple paths — later paths take higher precedence.

Expand All @@ -239,6 +242,15 @@ varlock scan --staged
# Scan all files, including gitignored ones
varlock scan --include-ignored

# Scan a specific build output directory (e.g. to check for leaked secrets before publishing)
varlock scan ./dist

# Scan multiple directories
varlock scan ./dist ./public

# Scan files matching a glob pattern
varlock scan './dist/**/*.js'

# Use a specific .env file as the schema entry point
varlock scan --path .env.prod

Expand Down
97 changes: 95 additions & 2 deletions packages/varlock/src/cli/commands/scan.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ Examples:
varlock scan --path .env.prod # Use a specific .env file as the schema entry point
varlock scan -p ./envs -p ./overrides # Use multiple schema entry points
varlock scan --install-hook # Set up as a git pre-commit hook
varlock scan ./dist # Scan a specific directory (e.g. a build output folder)
varlock scan ./dist ./public # Scan multiple directories
varlock scan './dist/**/*.js' # Scan files matching a glob pattern
`.trim(),
});

Expand Down Expand Up @@ -160,6 +163,85 @@ export async function walkDirectory(dir: string): Promise<Array<string>> {
return files;
}

/**
* Like walkDirectory but does NOT skip entries in SKIP_DIRS.
* Used when users explicitly pass a target directory to scan
* (e.g. a build output folder like `dist` or `.next`).
*/
export async function walkDirectoryAll(dir: string): Promise<Array<string>> {
const files: Array<string> = [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walkDirectoryAll(fullPath));
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) continue;
files.push(fullPath);
}
}
return files;
}

const GLOB_CHARS = /[*?{}[\]]/;

/**
* Resolves an array of path/glob strings (as provided by the user on the CLI)
* into a deduplicated list of absolute file paths to scan.
*
* - Glob patterns (containing `*`, `?`, `{`, `[`) are expanded with `fs.glob`.
* - Explicit directories are walked (without skipping build-output dirs).
* - Explicit files are included directly (if not a binary extension).
*/
export async function resolveTargetPaths(targets: Array<string>, cwd: string): Promise<Array<string>> {
const seen = new Set<string>();
const files: Array<string> = [];

async function addFile(absPath: string) {
if (seen.has(absPath)) return;
seen.add(absPath);
files.push(absPath);
}

async function addPath(absPath: string) {
let stat;
try {
stat = await fs.stat(absPath);
} catch {
return; // path doesn't exist — silently skip
}
if (stat.isDirectory()) {
for (const f of await walkDirectoryAll(absPath)) {
await addFile(f);
}
} else if (stat.isFile()) {
const ext = path.extname(absPath).toLowerCase();
if (!BINARY_EXTENSIONS.has(ext)) {
await addFile(absPath);
}
}
}

for (const target of targets) {
if (GLOB_CHARS.test(target)) {
// Expand glob pattern; paths returned by fsGlob are relative to cwd
for await (const match of fs.glob(target, { cwd })) {
await addPath(path.resolve(cwd, match));
}
} else {
await addPath(path.resolve(cwd, target));
}
}

return files;
}

/**
* Scans a single file for occurrences of any of the provided sensitive values.
* sensitiveValues is a map from env key name to its resolved string value.
Expand Down Expand Up @@ -372,6 +454,9 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
const onlyStaged = ctx.values.staged ?? false;
const includeIgnored = ctx.values['include-ignored'] ?? false;

// Positional arguments are treated as explicit paths/globs to scan
const scanTargets = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0);

// Load the varlock env graph to get the actual sensitive values
const envGraph = await loadVarlockEnvGraph({
entryFilePaths: ctx.values.path,
Expand Down Expand Up @@ -405,7 +490,13 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
const cwd = process.cwd();
let files: Array<string>;

if (includeIgnored) {
if (scanTargets.length > 0) {
// Explicit paths/globs provided — scan only those targets.
// We skip git-aware filtering so the user gets exactly what they asked for,
// and we do NOT apply SKIP_DIRS (e.g. `dist`, `.next`) since the whole point
// is often to scan a build output directory.
files = await resolveTargetPaths(scanTargets, cwd);
} else if (includeIgnored) {
// Walk the full directory tree, no git filtering
files = await walkDirectory(cwd);
} else {
Expand All @@ -425,7 +516,9 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
}

if (files.length === 0) {
if (onlyStaged) {
if (scanTargets.length > 0) {
console.log(ansis.green('✅ No files found at the specified path(s).'));
} else if (onlyStaged) {
console.log('No staged files to scan.');
} else {
console.log(ansis.green('✅ No files found to scan.'));
Expand Down
100 changes: 99 additions & 1 deletion packages/varlock/src/cli/commands/test/scan.command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { scanFileForValues, walkDirectory } from '../scan.command';
import {
scanFileForValues, walkDirectory, walkDirectoryAll, resolveTargetPaths,
} from '../scan.command';

describe('scanFileForValues', () => {
let tempDir: string;
Expand Down Expand Up @@ -155,3 +157,99 @@ describe('walkDirectory', () => {
expect(files[0]).toContain('script.ts');
});
});

describe('walkDirectoryAll', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-walkall-test-'));
});

afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

test('finds files in nested directories including build-output dirs', async () => {
fs.writeFileSync(path.join(tempDir, 'index.ts'), 'content');
fs.mkdirSync(path.join(tempDir, 'dist'));
fs.writeFileSync(path.join(tempDir, 'dist', 'bundle.js'), 'build output');
fs.mkdirSync(path.join(tempDir, 'node_modules'));
fs.writeFileSync(path.join(tempDir, 'node_modules', 'dep.js'), 'dep');
const files = await walkDirectoryAll(tempDir);
// Should include files inside dist AND node_modules (no directory is skipped)
expect(files).toHaveLength(3);
const names = files.map((f) => path.basename(f)).sort();
expect(names).toEqual(['bundle.js', 'dep.js', 'index.ts'].sort());
});

test('still skips binary file extensions', async () => {
fs.writeFileSync(path.join(tempDir, 'image.png'), 'fake png content');
fs.writeFileSync(path.join(tempDir, 'script.ts'), 'real content');
const files = await walkDirectoryAll(tempDir);
expect(files).toHaveLength(1);
expect(files[0]).toContain('script.ts');
});
});

describe('resolveTargetPaths', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-resolve-test-'));
});

afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

test('resolves an explicit directory', async () => {
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'content');
fs.writeFileSync(path.join(tempDir, 'b.ts'), 'content');
const files = await resolveTargetPaths([tempDir], tempDir);
expect(files).toHaveLength(2);
});

test('resolves an explicit file', async () => {
const filePath = path.join(tempDir, 'a.ts');
fs.writeFileSync(filePath, 'content');
const files = await resolveTargetPaths([filePath], tempDir);
expect(files).toHaveLength(1);
expect(files[0]).toBe(filePath);
});

test('skips non-existent paths silently', async () => {
const files = await resolveTargetPaths([path.join(tempDir, 'does-not-exist')], tempDir);
expect(files).toHaveLength(0);
});

test('deduplicates files when a file appears via multiple targets', async () => {
const filePath = path.join(tempDir, 'a.ts');
fs.writeFileSync(filePath, 'content');
// Pass both the directory and the file explicitly — should still yield one result
const files = await resolveTargetPaths([tempDir, filePath], tempDir);
expect(files).toHaveLength(1);
});

test('resolves glob patterns', async () => {
fs.writeFileSync(path.join(tempDir, 'a.ts'), 'content');
fs.writeFileSync(path.join(tempDir, 'b.ts'), 'content');
fs.writeFileSync(path.join(tempDir, 'c.md'), 'content');
const files = await resolveTargetPaths(['*.ts'], tempDir);
expect(files).toHaveLength(2);
for (const f of files) expect(f.endsWith('.ts')).toBe(true);
});

test('resolves a build-output directory (not skipped by SKIP_DIRS)', async () => {
// Simulate a dist folder which would normally be skipped by walkDirectory
const distDir = path.join(tempDir, 'dist');
fs.mkdirSync(distDir);
fs.writeFileSync(path.join(distDir, 'bundle.js'), 'build output');
const files = await resolveTargetPaths([distDir], tempDir);
expect(files).toHaveLength(1);
expect(files[0]).toContain('bundle.js');
});
});
Loading