Skip to content

Commit be0700f

Browse files
authored
fix: expire warning (#299)
* fix: expire warings strict mode * chore: expire warning json
1 parent fe56942 commit be0700f

7 files changed

Lines changed: 147 additions & 60 deletions

File tree

src/commands/scanUsage.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { printComparisonError } from '../ui/scan/printComparisonError.js';
1515
import { hasIgnoreComment } from '../core/security/secretDetectors.js';
1616
import { frameworkValidator } from '../core/frameworks/frameworkValidator.js';
1717
import { detectSecretsInExample } from '../core/security/exampleSecretDetector.js';
18-
import { DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
18+
import {
19+
DEFAULT_EXAMPLE_FILE,
20+
URGENT_EXPIRE_DAYS,
21+
EXPIRE_THRESHOLD_DAYS,
22+
} from '../config/constants.js';
1923
import { promptNoEnvScenario } from './prompts/promptNoEnvScenario.js';
2024

2125
/**
@@ -145,23 +149,30 @@ export async function scanUsage(opts: ScanUsageOptions): Promise<ExitResult> {
145149
scanResult.exampleWarnings ?? []
146150
).some((w) => w.severity === 'high');
147151

152+
const hasUrgentExpireWarnings = (scanResult.expireWarnings ?? []).some(
153+
(w) => w.daysLeft <= URGENT_EXPIRE_DAYS,
154+
);
155+
148156
return {
149157
exitWithError:
150158
scanResult.missing.length > 0 ||
151159
hasHighSeveritySecrets ||
160+
hasUrgentExpireWarnings ||
152161
hasHighSeverityExampleWarnings ||
153162
!!(
154-
(opts.strict &&
155-
(scanResult.unused.length > 0 ||
156-
(scanResult.duplicates?.env?.length ?? 0) > 0 ||
157-
(scanResult.duplicates?.example?.length ?? 0) > 0 ||
158-
(scanResult.secrets?.length ?? 0) > 0 ||
159-
(scanResult.frameworkWarnings?.length ?? 0) > 0 ||
160-
(scanResult.logged?.length ?? 0) > 0 ||
161-
(scanResult.uppercaseWarnings?.length ?? 0) > 0 ||
162-
(scanResult.expireWarnings?.length ?? 0) > 0 ||
163-
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0)) ||
164-
(scanResult.exampleWarnings?.length ?? 0) > 0
163+
opts.strict &&
164+
(scanResult.unused.length > 0 ||
165+
(scanResult.duplicates?.env?.length ?? 0) > 0 ||
166+
(scanResult.duplicates?.example?.length ?? 0) > 0 ||
167+
(scanResult.secrets?.length ?? 0) > 0 ||
168+
(scanResult.frameworkWarnings?.length ?? 0) > 0 ||
169+
(scanResult.logged?.length ?? 0) > 0 ||
170+
(scanResult.uppercaseWarnings?.length ?? 0) > 0 ||
171+
(scanResult.expireWarnings?.filter(
172+
(w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS,
173+
).length ?? 0) > 0 ||
174+
(scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ||
175+
(scanResult.exampleWarnings?.length ?? 0) > 0)
165176
),
166177
};
167178
}

src/config/constants.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,9 @@ export const ALLOWED_CATEGORIES = [
6969
/** * Threshold in days for showing expiration warnings for environment variables.
7070
* Variables expiring within this number of days or already expired will trigger a warning.
7171
*/
72-
export const EXPIRE_THRESHOLD_DAYS = 60;
72+
export const EXPIRE_THRESHOLD_DAYS = 20;
73+
74+
/** Threshold in days for showing urgent expiration warnings.
75+
* Variables expiring within this number of days or already expired will trigger a high-severity warning.
76+
*/
77+
export const URGENT_EXPIRE_DAYS = 7;

src/services/printScanResult.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import {
1010
DEFAULT_ENV_FILE,
1111
EXPIRE_THRESHOLD_DAYS,
12+
URGENT_EXPIRE_DAYS,
1213
} from '../config/constants.js';
1314
import { printHeader } from '../ui/scan/printHeader.js';
1415
import { printStats } from '../ui/scan/printStats.js';
@@ -107,7 +108,7 @@ export function printScanResult(
107108

108109
// Expiration warnings
109110
if (scanResult.expireWarnings) {
110-
printExpireWarnings(scanResult.expireWarnings);
111+
printExpireWarnings(scanResult.expireWarnings, opts.strict);
111112
}
112113
// Check for high severity secrets - ALWAYS exit with error
113114
const hasHighSeveritySecrets = (scanResult.secrets ?? []).some(
@@ -127,6 +128,14 @@ export function printScanResult(
127128
exitWithError = true;
128129
}
129130

131+
const hasUrgentExpireWarnings = (scanResult.expireWarnings ?? []).some(
132+
(w) => w.daysLeft <= URGENT_EXPIRE_DAYS,
133+
);
134+
135+
if (hasUrgentExpireWarnings) {
136+
exitWithError = true;
137+
}
138+
130139
// Gitignore check
131140
const gitignoreIssue = checkGitignoreStatus({
132141
cwd: opts.cwd,

src/ui/scan/printExpireWarnings.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import type { ExpireWarning } from '../../config/types.js';
2-
import { label, value, accent, error, warning, divider, header } from '../theme.js';
2+
import {
3+
label,
4+
value,
5+
error,
6+
warning,
7+
divider,
8+
header,
9+
} from '../theme.js';
310
import { EXPIRE_THRESHOLD_DAYS } from '../../config/constants.js';
411

512
/**
@@ -8,18 +15,19 @@ import { EXPIRE_THRESHOLD_DAYS } from '../../config/constants.js';
815
* @param warnings Array of expiration warnings
916
* @returns void
1017
*/
11-
export function printExpireWarnings(warnings: ExpireWarning[]): void {
18+
export function printExpireWarnings(
19+
warnings: ExpireWarning[],
20+
strict: boolean = false,
21+
): void {
1222
const relevant = warnings.filter((w) => w.daysLeft <= EXPIRE_THRESHOLD_DAYS);
1323
if (relevant.length === 0) return;
1424

15-
const mostUrgent = relevant.reduce((min, w) => Math.min(min, w.daysLeft), Infinity);
25+
const mostUrgent = relevant.reduce(
26+
(min, w) => Math.min(min, w.daysLeft),
27+
Infinity,
28+
);
1629

17-
const indicator =
18-
mostUrgent <= 0
19-
? error('▸')
20-
: mostUrgent <= 7
21-
? warning('▸')
22-
: accent('▸');
30+
const indicator = strict || mostUrgent <= 7 ? error('▸') : warning('▸');
2331

2432
console.log();
2533
console.log(`${indicator} ${header('Expiration warnings')}`);
@@ -28,14 +36,19 @@ export function printExpireWarnings(warnings: ExpireWarning[]): void {
2836
const days = (n: number) => `${n} ${n === 1 ? 'day' : 'days'}`;
2937

3038
for (const warn of relevant) {
31-
const status =
39+
const statusText =
3240
warn.daysLeft <= 0
33-
? error(`expired ${days(Math.abs(warn.daysLeft))} ago`)
34-
: warn.daysLeft <= 7
35-
? warning(`expires in ${days(warn.daysLeft)}`)
36-
: value(`expires in ${days(warn.daysLeft)}`);
41+
? `expired ${days(Math.abs(warn.daysLeft))} ago`
42+
: `expires in ${days(warn.daysLeft)}`;
3743

38-
console.log(`${label(warn.key.padEnd(26))}${status}`);
44+
const rowColor =
45+
strict || warn.daysLeft <= 7
46+
? error
47+
: warn.daysLeft <= EXPIRE_THRESHOLD_DAYS
48+
? warning
49+
: value;
50+
51+
console.log(`${label(warn.key.padEnd(26))}${rowColor(statusText)}`);
3952
}
4053

4154
console.log(`${divider}`);

test/e2e/cli.detectExpired.e2e.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('Expiration Warnings', () => {
5050

5151
const res = runCli(cwd, []);
5252

53-
expect(res.status).toBe(0);
53+
expect(res.status).toBe(1);
5454
expect(res.stdout).toContain('Expiration warnings');
5555
expect(res.stdout).toContain('EXPIRED_API_KEY');
5656
expect(res.stdout).toContain('EXPIRED');
@@ -78,7 +78,7 @@ describe('Expiration Warnings', () => {
7878

7979
const res = runCli(cwd, []);
8080

81-
expect(res.status).toBe(0);
81+
expect(res.status).toBe(1);
8282
expect(res.stdout).toContain('Expiration warnings');
8383
expect(res.stdout).toContain('SOON_EXPIRED_KEY');
8484
expect(res.stdout).toContain('expires in 3 days');
@@ -133,7 +133,7 @@ describe('Expiration Warnings', () => {
133133

134134
const res = runCli(cwd, []);
135135

136-
expect(res.status).toBe(0);
136+
expect(res.status).toBe(1);
137137
expect(res.stdout).toContain('Expiration warnings');
138138
expect(res.stdout).toContain('OLD_TOKEN');
139139
expect(res.stdout).toContain('NEW_TOKEN');
@@ -187,7 +187,7 @@ describe('Expiration Warnings', () => {
187187

188188
const res = runCli(cwd, []);
189189

190-
expect(res.status).toBe(0);
190+
expect(res.status).toBe(1);
191191
expect(res.stdout).toContain('Expiration warnings');
192192
expect(res.stdout).toContain('JS_STYLE_KEY');
193193
expect(res.stdout).toContain('SHELL_STYLE_KEY');
@@ -242,7 +242,7 @@ describe('Expiration Warnings', () => {
242242

243243
const res = runCli(cwd, ['--json']);
244244

245-
expect(res.status).toBe(0);
245+
expect(res.status).toBe(1);
246246
expect(res.stdout).not.toContain('Expiration warnings');
247247
expect(res.stdout).toContain('"expireWarnings"');
248248
});

test/unit/commands/scanUsage.test.ts

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ describe('scanUsage', () => {
108108
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
109109
vi.mocked(printScanResult).mockReturnValue({ exitWithError: false });
110110
vi.mocked(printMissingExample).mockReturnValue(false);
111-
vi.mocked(promptNoEnvScenario).mockResolvedValue({ compareFile: undefined });
111+
vi.mocked(promptNoEnvScenario).mockResolvedValue({
112+
compareFile: undefined,
113+
});
112114
});
113115

114116
it('returns early when example missing in CI mode', async () => {
@@ -158,6 +160,24 @@ describe('scanUsage', () => {
158160
expect(result.exitWithError).toBe(true);
159161
});
160162

163+
it('does not return error in JSON mode for non-high example warnings when strict is false', async () => {
164+
vi.mocked(scanCodebase).mockResolvedValue({
165+
...baseScanResult,
166+
exampleWarnings: [
167+
{
168+
key: 'EXAMPLE_KEY',
169+
value: 'placeholder-but-flagged',
170+
reason: 'Entropy',
171+
severity: 'medium',
172+
},
173+
],
174+
} as any);
175+
176+
const result = await scanUsage({ ...baseOpts, json: true, strict: false });
177+
178+
expect(result.exitWithError).toBe(false);
179+
});
180+
161181
it('returns strict error in JSON mode when strict violations exist', async () => {
162182
vi.mocked(scanCodebase).mockResolvedValue({
163183
...baseScanResult,
@@ -173,40 +193,56 @@ describe('scanUsage', () => {
173193
expect(result.exitWithError).toBe(true);
174194
});
175195

176-
it('skips prompt when type is none and isCiMode is true', async () => {
177-
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
196+
it('returns error in JSON mode when expiration warning is urgent (<=7 days)', async () => {
197+
vi.mocked(scanCodebase).mockResolvedValue({
198+
...baseScanResult,
199+
expireWarnings: [{ key: 'TOKEN', date: '2026-03-10', daysLeft: 7 }],
200+
});
178201

179-
const result = await scanUsage({ ...baseOpts, isCiMode: true });
202+
const result = await scanUsage({ ...baseOpts, json: true, strict: false });
180203

181-
expect(promptNoEnvScenario).not.toHaveBeenCalled();
182-
expect(result.exitWithError).toBe(false);
183-
});
204+
expect(result.exitWithError).toBe(true);
205+
});
184206

185-
it('skips prompt when type is none and json is true', async () => {
186-
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
207+
it('skips prompt when type is none and isCiMode is true', async () => {
208+
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
187209

188-
const result = await scanUsage({ ...baseOpts, json: true });
210+
const result = await scanUsage({ ...baseOpts, isCiMode: true });
189211

190-
expect(promptNoEnvScenario).not.toHaveBeenCalled();
191-
expect(result.exitWithError).toBe(false);
192-
});
212+
expect(promptNoEnvScenario).not.toHaveBeenCalled();
213+
expect(result.exitWithError).toBe(false);
214+
});
193215

194-
it('calls promptNoEnvScenario when type is none and not CI/json', async () => {
195-
vi.mocked(promptNoEnvScenario).mockResolvedValue({
196-
compareFile: { path: '/env/.env', name: '.env' },
216+
it('skips prompt when type is none and json is true', async () => {
217+
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
218+
219+
const result = await scanUsage({ ...baseOpts, json: true });
220+
221+
expect(promptNoEnvScenario).not.toHaveBeenCalled();
222+
expect(result.exitWithError).toBe(false);
197223
});
198-
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
199-
vi.mocked(processComparisonFile).mockReturnValue({
200-
scanResult: { ...baseScanResult },
201-
comparedAgainst: '.env',
202-
fix: { fixApplied: false, removedDuplicates: [], addedEnv: [], gitignoreUpdated: false },
203-
} as any);
204224

205-
await scanUsage({ ...baseOpts, isCiMode: false, json: false });
225+
it('calls promptNoEnvScenario when type is none and not CI/json', async () => {
226+
vi.mocked(promptNoEnvScenario).mockResolvedValue({
227+
compareFile: { path: '/env/.env', name: '.env' },
228+
});
229+
vi.mocked(determineComparisonFile).mockResolvedValue({ type: 'none' });
230+
vi.mocked(processComparisonFile).mockReturnValue({
231+
scanResult: { ...baseScanResult },
232+
comparedAgainst: '.env',
233+
fix: {
234+
fixApplied: false,
235+
removedDuplicates: [],
236+
addedEnv: [],
237+
gitignoreUpdated: false,
238+
},
239+
} as any);
240+
241+
await scanUsage({ ...baseOpts, isCiMode: false, json: false });
206242

207-
expect(promptNoEnvScenario).toHaveBeenCalled();
208-
expect(processComparisonFile).toHaveBeenCalled();
209-
});
243+
expect(promptNoEnvScenario).toHaveBeenCalled();
244+
expect(processComparisonFile).toHaveBeenCalled();
245+
});
210246

211247
it('sets frameworkWarnings on scanResult when frameworkValidator returns results', async () => {
212248
const { frameworkValidator } =

test/unit/services/printScanResult.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,19 @@ describe('printScanResult', () => {
301301
expect(result.exitWithError).toBe(true);
302302
});
303303

304+
it('returns exitWithError true when expiration warning is urgent (<=7 days)', () => {
305+
const result = printScanResult(
306+
{
307+
...baseScanResult,
308+
expireWarnings: [{ key: 'TOKEN', daysLeft: 7 } as any],
309+
},
310+
baseOpts,
311+
'.env',
312+
);
313+
314+
expect(result.exitWithError).toBe(true);
315+
});
316+
304317
it('checks gitignore status with cwd and default env file', () => {
305318
printScanResult(baseScanResult, baseOpts, '.env');
306319

0 commit comments

Comments
 (0)