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/diffmatrix-prototype-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'dotenv-diff': patch
---

fix matrix mode (`--matrix`) misreporting keys that share a name with a built in object member
6 changes: 4 additions & 2 deletions packages/cli/src/core/diffMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ function buildRow(
files: MatrixFileInput[],
checkValues: boolean,
): MatrixRow {
const presence = files.map((f) => key in f.values);
const values = files.map((f) => f.values[key]);
const presence = files.map((f) => Object.hasOwn(f.values, key));
const values = files.map((f) =>
Object.hasOwn(f.values, key) ? f.values[key] : undefined,
);

const hasMismatch =
checkValues && new Set(values.filter((_, i) => presence[i])).size > 1;
Expand Down
147 changes: 147 additions & 0 deletions packages/cli/test/property/core/diffMatrix.property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, test, expect } from 'vitest';
import fc from 'fast-check';
import { diffMatrix } from '../../../src/core/diffMatrix.js';
import type { MatrixFileInput } from '../../../src/config/types.js';

/**
* Property-based ("fuzz") tests for the env matrix diff.
*
* `diffMatrix` builds a key-presence matrix across N files. Because each file's
* values live in a plain object, a naive `key in obj` / `obj[key]` would see
* inherited members (`toString`, `constructor`, …) and report phantom presence.
* These generate thousands of random file sets — deliberately seeded with those
* prototype key names — and check the result against an independent own-property
* oracle: presence is own-property truth, values are the real string or
* undefined (never an inherited function), rows are the sorted key union, and
* `allMatch` follows from the rows. Property-based testing is also what OpenSSF
* Scorecard recognises as fuzzing for JS/TS.
*/

const PROTO_KEYS = [
'toString',
'constructor',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
'__proto__',
];

const keyArb = fc.oneof(
fc.stringMatching(/^[A-Z_][A-Z0-9_]{0,6}$/),
fc.constantFrom(...PROTO_KEYS),
fc.string(),
);

const fileArb: fc.Arbitrary<MatrixFileInput> = fc.record({
name: fc.string(),
values: fc
.array(fc.tuple(keyArb, fc.string()), { maxLength: 8 })
// Object.fromEntries defines *own* properties, so even '__proto__' becomes a
// real own key — matching how the parser would store a literal `__proto__=x`.
.map((pairs) => Object.fromEntries(pairs) as Record<string, string>),
});

const filesArb = fc.array(fileArb, { maxLength: 5 });

/** Independent, own-property-correct re-derivation of the whole result. */
function oracle(files: MatrixFileInput[], checkValues: boolean) {
const keys = [...new Set(files.flatMap((f) => Object.keys(f.values)))].sort();
const rows = keys.map((key) => {
const presence = files.map((f) => Object.hasOwn(f.values, key));
const values = files.map((f, i) =>
presence[i] ? f.values[key] : undefined,
);
const present = values.filter((_, i) => presence[i]);
const hasMismatch = checkValues && new Set(present).size > 1;
return { key, presence, values, hasMismatch };
});
const allMatch = rows.every(
(r) => r.presence.every(Boolean) && !r.hasMismatch,
);
return { files: files.map((f) => f.name), rows, allMatch };
}

describe('diffMatrix (property-based)', () => {
test('never throws on arbitrary file sets', () => {
fc.assert(
fc.property(filesArb, fc.boolean(), (files, checkValues) => {
diffMatrix(files, checkValues);
}),
{ numRuns: 3000 },
);
});

test('matches the own-property oracle exactly', () => {
fc.assert(
fc.property(filesArb, fc.boolean(), (files, checkValues) => {
expect(diffMatrix(files, checkValues)).toEqual(
oracle(files, checkValues),
);
}),
{ numRuns: 5000 },
);
});

test('presence and values never leak inherited prototype members', () => {
fc.assert(
fc.property(filesArb, fc.boolean(), (files, checkValues) => {
const res = diffMatrix(files, checkValues);
for (const row of res.rows) {
row.presence.forEach((present, i) => {
// A file is only "present" if it owns the key.
expect(present).toBe(Object.hasOwn(files[i]!.values, row.key));
// Values are strings or undefined — never a leaked function.
const v = row.values[i];
expect(v === undefined || typeof v === 'string').toBe(true);
if (!present) expect(v).toBeUndefined();
});
}
}),
{ numRuns: 4000 },
);
});

test('rows are the sorted union of all files’ own keys, aligned to file count', () => {
fc.assert(
fc.property(filesArb, (files) => {
const res = diffMatrix(files);
const expectedKeys = [
...new Set(files.flatMap((f) => Object.keys(f.values))),
].sort();
expect(res.rows.map((r) => r.key)).toEqual(expectedKeys);
expect(res.files).toEqual(files.map((f) => f.name));
for (const row of res.rows) {
expect(row.presence).toHaveLength(files.length);
expect(row.values).toHaveLength(files.length);
}
}),
{ numRuns: 3000 },
);
});

test('allMatch is exactly "every key present everywhere with no mismatch"', () => {
fc.assert(
fc.property(filesArb, fc.boolean(), (files, checkValues) => {
const res = diffMatrix(files, checkValues);
const expected = res.rows.every(
(r) => r.presence.every(Boolean) && !r.hasMismatch,
);
expect(res.allMatch).toBe(expected);
}),
{ numRuns: 3000 },
);
});

test('without checkValues, no row is ever flagged as a mismatch', () => {
fc.assert(
fc.property(filesArb, (files) => {
for (const row of diffMatrix(files, false).rows) {
expect(row.hasMismatch).toBe(false);
}
}),
{ numRuns: 3000 },
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, test, expect } from 'vitest';
import fc from 'fast-check';
import type { ScanResult } from '../../../../src/config/types.js';
import { computeHealthScore } from '../../../../src/core/scan/computeHealthScore.js';

/**
* Property-based ("fuzz") tests for the scan health score.
*
* The score starts at 100 and subtracts a fixed weight per issue in each
* category, then clamps to [0, 100]. These generate thousands of random issue
* count combinations to prove the score is always an integer inside [0, 100], a
* fully clean scan scores 100, more issues never raise the score (monotonic),
* only high/medium secrets are penalised, and the total matches the documented
* per-category weights. Property-based testing is also what OpenSSF Scorecard
* recognises as fuzzing for JS/TS.
*/

interface Counts {
highSecrets: number;
medSecrets: number;
lowSecrets: number;
missing: number;
uppercase: number;
logged: number;
unused: number;
framework: number;
example: number;
expire: number;
inconsistent: number;
dupEnv: number;
dupExample: number;
}

const fill = (n: number) => Array.from({ length: n }, () => ({}));

/** Build a minimal ScanResult carrying exactly the requested issue counts. */
function buildScan(c: Counts): ScanResult {
return {
missing: fill(c.missing),
unused: fill(c.unused),
logged: fill(c.logged),
uppercaseWarnings: fill(c.uppercase),
frameworkWarnings: fill(c.framework),
exampleWarnings: fill(c.example),
expireWarnings: fill(c.expire),
inconsistentNamingWarnings: fill(c.inconsistent),
secrets: [
...Array.from({ length: c.highSecrets }, () => ({ severity: 'high' })),
...Array.from({ length: c.medSecrets }, () => ({ severity: 'medium' })),
...Array.from({ length: c.lowSecrets }, () => ({ severity: 'low' })),
],
duplicates: { env: fill(c.dupEnv), example: fill(c.dupExample) },
} as unknown as ScanResult;
}

/** Independent re-derivation of the documented weighting. */
function expectedScore(c: Counts): number {
const raw =
100 -
c.highSecrets * 20 -
c.medSecrets * 10 -
c.missing * 20 -
c.uppercase * 2 -
c.logged * 10 -
c.unused * 1 -
c.framework * 5 -
c.example * 10 -
c.expire * 5 -
c.inconsistent * 3 -
c.dupEnv * 10 -
c.dupExample * 10;
return Math.max(0, Math.min(100, raw));
}

const countsArb: fc.Arbitrary<Counts> = fc.record({
highSecrets: fc.nat({ max: 8 }),
medSecrets: fc.nat({ max: 8 }),
lowSecrets: fc.nat({ max: 8 }),
missing: fc.nat({ max: 8 }),
uppercase: fc.nat({ max: 8 }),
logged: fc.nat({ max: 8 }),
unused: fc.nat({ max: 8 }),
framework: fc.nat({ max: 8 }),
example: fc.nat({ max: 8 }),
expire: fc.nat({ max: 8 }),
inconsistent: fc.nat({ max: 8 }),
dupEnv: fc.nat({ max: 8 }),
dupExample: fc.nat({ max: 8 }),
});

describe('computeHealthScore (property-based)', () => {
test('result is always an integer in [0, 100]', () => {
fc.assert(
fc.property(countsArb, (c) => {
const score = computeHealthScore(buildScan(c));
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
}),
{ numRuns: 5000 },
);
});

test('matches the documented per-category weighting', () => {
fc.assert(
fc.property(countsArb, (c) => {
expect(computeHealthScore(buildScan(c))).toBe(expectedScore(c));
}),
{ numRuns: 5000 },
);
});

test('a fully clean scan scores 100', () => {
const clean: Counts = {
highSecrets: 0,
medSecrets: 0,
lowSecrets: 0,
missing: 0,
uppercase: 0,
logged: 0,
unused: 0,
framework: 0,
example: 0,
expire: 0,
inconsistent: 0,
dupEnv: 0,
dupExample: 0,
};
expect(computeHealthScore(buildScan(clean))).toBe(100);
});

test('monotonic: adding issues never raises the score', () => {
const nonNeg = fc.record({
highSecrets: fc.nat({ max: 5 }),
medSecrets: fc.nat({ max: 5 }),
lowSecrets: fc.nat({ max: 5 }),
missing: fc.nat({ max: 5 }),
uppercase: fc.nat({ max: 5 }),
logged: fc.nat({ max: 5 }),
unused: fc.nat({ max: 5 }),
framework: fc.nat({ max: 5 }),
example: fc.nat({ max: 5 }),
expire: fc.nat({ max: 5 }),
inconsistent: fc.nat({ max: 5 }),
dupEnv: fc.nat({ max: 5 }),
dupExample: fc.nat({ max: 5 }),
});
fc.assert(
fc.property(countsArb, nonNeg, (base, extra) => {
const bigger: Counts = { ...base };
for (const k of Object.keys(base) as (keyof Counts)[]) {
bigger[k] = base[k] + extra[k];
}
expect(computeHealthScore(buildScan(bigger))).toBeLessThanOrEqual(
computeHealthScore(buildScan(base)),
);
}),
{ numRuns: 5000 },
);
});

test('low-severity secrets do not affect the score', () => {
fc.assert(
fc.property(countsArb, fc.nat({ max: 20 }), (c, extraLow) => {
const withLow: Counts = { ...c, lowSecrets: c.lowSecrets + extraLow };
expect(computeHealthScore(buildScan(withLow))).toBe(
computeHealthScore(buildScan(c)),
);
}),
{ numRuns: 3000 },
);
});

test('never throws when optional fields are absent', () => {
// Only the non-optional `missing` array is guaranteed present in the type.
fc.assert(
fc.property(fc.array(fc.string(), { maxLength: 30 }), (missing) => {
const score = computeHealthScore({ missing } as unknown as ScanResult);
expect(score).toBe(Math.max(0, 100 - missing.length * 20));
}),
{ numRuns: 2000 },
);
});
});
Loading