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
5 changes: 5 additions & 0 deletions .changeset/slow-times-sink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

regex to catch svelte v3 env variables
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: javascript-typescript
build-mode: none

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: '/language:javascript-typescript'
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ jobs:
retention-days: 5

- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: results.sarif
10 changes: 10 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ const { MY_KEY } = privateEnv
import { MY_KEY } from '$env/static/private';
import { MY_KEY } from '$env/static/public';
MY_KEY

// SvelteKit 3 – named imports from $app/env
import { MY_KEY } from '$app/env/private';
import { MY_KEY, OTHER_KEY as alias } from '$app/env/public';

// SvelteKit 3 – declarations in src/env.ts (top-level keys count as used)
export const variables = defineEnvVars({
MY_KEY: {},
OTHER_KEY: { public: true },
});
```

Docker Compose files are also scanned for shell-style interpolation, so
Expand Down
19 changes: 18 additions & 1 deletion docs/frameworks/sveltekit_warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,23 @@ Warning:

`$env/static/public variables must start with "PUBLIC_"`

## 9 Sensitive-looking `PUBLIC_` / `VITE_` names trigger exposure warnings
## 9 `$app/env/private` cannot be used in client-side code

SvelteKit 3 replaces the `$env/*` modules with `$app/env/private` and `$app/env/public`.

```svelte
<script lang="ts">
import { SECRET_KEY } from '$app/env/private';
</script>
```

Warning:

`$app/env/private cannot be used in client-side code`

**Note:** `$app/env/public` has no `PUBLIC_` prefix rule. In SvelteKit 3 a variable is exposed to the browser via `public: true` in `defineEnvVars`, not via its name.

## 10 Sensitive-looking `PUBLIC_` / `VITE_` names trigger exposure warnings

If a client-exposed name contains `SECRET`, `PRIVATE`, or `PASSWORD`, a warning is produced.

Expand All @@ -128,6 +144,7 @@ Warning:
- `$env/dynamic/public` → must use `PUBLIC_*`
- `$env/static/private` → server-only, never `PUBLIC_*`
- `$env/static/public` → must use `PUBLIC_*`
- `$app/env/private` → server-only
- Sensitive client-exposed names (`PUBLIC_*`/`VITE_*`) → warning

## Best practices
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@
"@repo/eslint-config": "workspace:*",
"@repo/tsconfig": "workspace:*",
"@repo/vitest": "workspace:*",
"@types/node": "^26.6.4",
"@types/node": "^26.6.5",
"@types/prompts": "^2.4.9",
"@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.71.0",
"@typescript-eslint/eslint-plugin": "^8.71.2",
"@typescript-eslint/parser": "^8.71.2",
"@vitest/coverage-v8": "5.0.3",
"eslint": "^10.12.0",
"eslint": "^10.13.0",
"husky": "^9.1.7",
"lint-staged": "^17.6.0",
"prettier": "^3.9.9",
"turbo": "^2.11.7",
"prettier": "^3.9.10",
"turbo": "^2.11.9",
"typescript": "^6.0.3",
"vitest": "^5.0.3"
"vitest": "^5.0.5"
},
"packageManager": "pnpm@11.24.0"
}
6 changes: 3 additions & 3 deletions packages/@repo/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"format": "prettier --write \"**/*.{ts,js,json,md,yml}\""
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.71.0",
"@typescript-eslint/parser": "^8.71.0",
"eslint": "^10.12.0"
"@typescript-eslint/eslint-plugin": "^8.71.2",
"@typescript-eslint/parser": "^8.71.2",
"eslint": "^10.13.0"
}
}
6 changes: 3 additions & 3 deletions packages/cli/src/commands/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,11 @@ function findDuplicates(
function isAllOk(filtered: Filtered): boolean {
return (
filtered.missing.length === 0 &&
(filtered.extra?.length ?? 0) === 0 &&
(filtered.empty?.length ?? 0) === 0 &&
!filtered.extra?.length &&
!filtered.empty?.length &&
filtered.duplicatesEnv.length === 0 &&
filtered.duplicatesEx.length === 0 &&
(filtered.mismatches?.length ?? 0) === 0 &&
!filtered.mismatches?.length &&
!filtered.gitignoreIssue
);
}
16 changes: 16 additions & 0 deletions packages/cli/src/core/frameworks/sveltekitRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ export function applySvelteKitRules(
return;
}

// $app/env/private (SvelteKit 3)
if (
u.pattern === 'sveltekit' &&
u.imports?.includes('$app/env/private') &&
(isSvelteFile || isClientFile)
) {
warnings.push({
variable: u.variable,
reason: '$app/env/private cannot be used in client-side code',
file: normalizedFile,
line: u.line,
framework: 'sveltekit',
});
return;
}

// Warn if PUBLIC_ or VITE_ contains sensitive keywords
if (
(u.variable.startsWith('PUBLIC_') || u.variable.startsWith('VITE_')) &&
Expand Down
117 changes: 115 additions & 2 deletions packages/cli/src/core/scan/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ type Pattern = {
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;

/**
* Matches SvelteKit $env imports to collect import sources per file.
* Matches SvelteKit env imports to collect import sources per file.
* Covers the legacy `$env/*` modules and the SvelteKit 3 `$app/env/*` modules.
* Example: import { env } from '$env/dynamic/private'
* Example: import { PUBLIC_URL } from '$env/static/public'
* Example: import { API_KEY } from '$app/env/private'
*/
export const SVELTEKIT_IMPORT_REGEX =
/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"](?<module>\$env\/(?:static|dynamic)\/(?:private|public))['"]/g;
/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"](?<module>\$env\/(?:static|dynamic)\/(?:private|public)|\$app\/env\/(?:private|public))['"]/g;

/**
* Matches aliased SvelteKit env imports.
Expand Down Expand Up @@ -71,6 +73,92 @@ export function buildSveltekitAliasPatterns(
];
}

/**
* Extracts the imported names from a named import list, ignoring local aliases.
* Example: "A, B as b, type C" → ["A", "B", "C"]
*/
function parseNamedImports(content: string | undefined): string[] {
if (!content) return [];
return content
.split(',')
.map((part) => part.trim().replace(/^type\s+/, ''))
.map((part) => part.split(/\s+as\s+/)[0]!.trim())
.filter((key) => ENV_VAR_NAME_PATTERN.test(key));
}

/**
* Extracts the top-level keys of the object literal passed to
* `defineEnvVars({ ... })` (SvelteKit 3 `src/env.ts`). Nested config objects
* (`{ public: true, schema: ... }`), strings and comments are skipped.
* @param source - The full file content.
* @param start - Offset of the opening `{` of the object literal.
*/
function parseDefineEnvVarsKeys(source: string, start: number): string[] {
const keys: string[] = [];
let depth = 0;
// True when the next token at depth 1 is in key position.
let expectKey = false;

for (let i = start; i < source.length; i++) {
const ch = source[i]!;

// Skip comments
if (ch === '/' && source[i + 1] === '/') {
const end = source.indexOf('\n', i);
i = end === -1 ? source.length : end;
continue;
}
if (ch === '/' && source[i + 1] === '*') {
const end = source.indexOf('*/', i + 2);
i = end === -1 ? source.length : end + 1;
continue;
}

// Strings (quoted keys at depth 1 are captured, everything else skipped)
if (ch === '"' || ch === "'" || ch === '`') {
let j = i + 1;
while (j < source.length && source[j] !== ch) {
if (source[j] === '\\') j++;
j++;
}
if (depth === 1 && expectKey) {
const key = source.slice(i + 1, j);
if (ENV_VAR_NAME_PATTERN.test(key)) keys.push(key);
expectKey = false;
}
i = j;
continue;
}

if (ch === '{' || ch === '[' || ch === '(') {
depth++;
if (depth === 1) expectKey = true;
continue;
}
if (ch === '}' || ch === ']' || ch === ')') {
depth--;
if (depth === 0) break;
continue;
}

if (depth !== 1) continue;

if (ch === ',') {
expectKey = true;
continue;
}

if (expectKey && /[A-Za-z_$]/.test(ch)) {
const ident = /^[A-Za-z_$][\w$]*/.exec(source.slice(i))![0];
if (ENV_VAR_NAME_PATTERN.test(ident)) keys.push(ident);
expectKey = false;
i += ident.length - 1;
}
}

return keys;
}

/**
* Framework-specific regex patterns for detecting environment variable usage
* across different runtimes and frameworks.
Expand Down Expand Up @@ -156,6 +244,31 @@ export const ENV_PATTERNS: Pattern[] = [
/import\s*\{\s*([A-Z_][A-Z0-9_]*)\s*\}\s*from\s*['"]\$env\/static\/(?:private|public)['"]/g,
},

// SvelteKit 3 named imports (single, multiple, aliased, multiline)
// import { PUBLIC_SENTRY_DSN } from '$app/env/public';
// import { API_KEY, OTHER_KEY as other } from '$app/env/private';
{
name: 'sveltekit' as const,
regex: /import\s*\{([^}]*)\}\s*from\s*['"]\$app\/env\/private['"]/g,
sourceModule: '$app/env/private',
processor: (match) => parseNamedImports(match[1]),
},
{
name: 'sveltekit' as const,
regex: /import\s*\{([^}]*)\}\s*from\s*['"]\$app\/env\/public['"]/g,
sourceModule: '$app/env/public',
processor: (match) => parseNamedImports(match[1]),
},

// SvelteKit 3 env declarations in src/env.ts
// export const variables = defineEnvVars({ API_KEY: {}, PUBLIC_URL: { public: true } });
{
name: 'sveltekit' as const,
regex: /\bdefineEnvVars\s*\(\s*(?=\{)/g,
processor: (match) =>
parseDefineEnvVarsKeys(match.input, match.index + match[0].length),
},

// Bare env object accessor (framework resolved from file context at scan time)
// env.SECRET — SvelteKit `$env/dynamic/*` or Vite `loadEnv(...)` result
{
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/test/unit/core/frameworks/sveltekitRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ describe('applySvelteKitRules', () => {
vi.clearAllMocks();
});

// $app/env/private (SvelteKit 3)
it('warns when $app/env/private is used in client code', () => {
applySvelteKitRules(
{
...baseUsage,
pattern: 'sveltekit',
variable: 'API_KEY',
file: 'src/routes/+page.svelte',
imports: ['$app/env/private'],
},
warnings,
);
expect(warnings).toHaveLength(1);
expect(warnings[0]!.reason).toContain('$app/env/private');
});

it('allows $app/env/private in server files and $app/env/public anywhere', () => {
applySvelteKitRules(
{
...baseUsage,
pattern: 'sveltekit',
variable: 'API_KEY',
file: 'src/routes/+page.server.ts',
imports: ['$app/env/private'],
},
warnings,
);
applySvelteKitRules(
{
...baseUsage,
pattern: 'sveltekit',
variable: 'SENTRY_DSN',
file: 'src/routes/+page.svelte',
imports: ['$app/env/public'],
},
warnings,
);
expect(warnings).toHaveLength(0);
});

it('ignores node_modules', () => {
applySvelteKitRules(
{ ...baseUsage, file: 'src/node_modules/pkg/index.js' },
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/unit/core/scan/computeExitDecision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ describe('computeExitDecision', () => {
});
}

it('passes under --strict when secrets and logged are undefined', () => {
const result = computeExitDecision(
scan({
secrets: undefined,
logged: undefined,
} as unknown as Partial<ScanResult>),
{ strict: true },
);
expect(result.exitWithError).toBe(false);
});

it('does not fail on expire warnings beyond the strict threshold', () => {
const result = computeExitDecision(
scan({
Expand Down
Loading
Loading