332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
// tests/unit/build/check-secrets.test.ts
|
|
// TDD unit tests for scripts/check/check-secrets.mjs — Task 7.18 gitleaks.
|
|
//
|
|
// Strategy: test the exported pure function without spawning gitleaks.
|
|
// All fixtures are synthetic gitleaks --report-format json outputs.
|
|
// - parseGitleaksJson() — parses gitleaks findings array
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
parseGitleaksJson,
|
|
evaluateSecretsRatchet,
|
|
readBaselineSecretsValue,
|
|
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
|
} from "../../../scripts/check/check-secrets.mjs";
|
|
|
|
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
|
const evaluate = evaluateSecretsRatchet as (current: number, baseline: number) => RatchetVerdict;
|
|
const readBaseline = readBaselineSecretsValue as (p?: string) => number | null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures — synthetic gitleaks --report-format json output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Helper to build a minimal gitleaks finding. */
|
|
function makeFinding(
|
|
overrides: {
|
|
ruleId?: string;
|
|
file?: string;
|
|
description?: string;
|
|
startLine?: number;
|
|
secret?: string;
|
|
} = {}
|
|
) {
|
|
return {
|
|
Description: overrides.description ?? "GitHub Personal Access Token",
|
|
StartLine: overrides.startLine ?? 42,
|
|
EndLine: overrides.startLine ?? 42,
|
|
StartColumn: 15,
|
|
EndColumn: 50,
|
|
Match: "REDACTED",
|
|
Secret: overrides.secret ?? "ghp_REDACTED",
|
|
File: overrides.file ?? "src/config/secrets.ts",
|
|
SymlinkFile: "",
|
|
Commit: "",
|
|
Entropy: 4.5,
|
|
Author: "Developer",
|
|
Email: "dev@example.com",
|
|
Date: "2026-01-01T00:00:00Z",
|
|
Message: "add config",
|
|
Tags: [],
|
|
RuleID: overrides.ruleId ?? "github-pat",
|
|
Fingerprint: "abc123",
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — input inválido / vazio
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: null retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson(null);
|
|
assert.equal(result.findingCount, 0);
|
|
assert.deepEqual(result.byRule, {});
|
|
assert.deepEqual(result.byFile, {});
|
|
});
|
|
|
|
test("parseGitleaksJson: undefined retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson(undefined as unknown as null);
|
|
assert.equal(result.findingCount, 0);
|
|
});
|
|
|
|
test("parseGitleaksJson: array vazio retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson([]);
|
|
assert.equal(result.findingCount, 0);
|
|
assert.deepEqual(result.byRule, {});
|
|
assert.deepEqual(result.byFile, {});
|
|
});
|
|
|
|
test("parseGitleaksJson: objeto (não-array) retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson({ RuleID: "github-pat" } as unknown as null);
|
|
assert.equal(result.findingCount, 0);
|
|
});
|
|
|
|
test("parseGitleaksJson: string retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson("findings" as unknown as null);
|
|
assert.equal(result.findingCount, 0);
|
|
});
|
|
|
|
test("parseGitleaksJson: número retorna findingCount=0", () => {
|
|
const result = parseGitleaksJson(42 as unknown as null);
|
|
assert.equal(result.findingCount, 0);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — contagem básica
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: 1 finding retorna findingCount=1", () => {
|
|
const result = parseGitleaksJson([makeFinding()]);
|
|
assert.equal(result.findingCount, 1);
|
|
});
|
|
|
|
test("parseGitleaksJson: 3 findings retorna findingCount=3", () => {
|
|
const findings = [
|
|
makeFinding({ ruleId: "github-pat", file: "src/a.ts" }),
|
|
makeFinding({ ruleId: "aws-access-key", file: "src/b.ts" }),
|
|
makeFinding({ ruleId: "generic-api-key", file: "src/c.ts" }),
|
|
];
|
|
const result = parseGitleaksJson(findings);
|
|
assert.equal(result.findingCount, 3);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — agrupamento por RuleID
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: agrupa por RuleID em byRule", () => {
|
|
const findings = [
|
|
makeFinding({ ruleId: "github-pat" }),
|
|
makeFinding({ ruleId: "aws-access-key" }),
|
|
makeFinding({ ruleId: "github-pat" }), // segundo github-pat
|
|
];
|
|
const result = parseGitleaksJson(findings);
|
|
assert.equal(result.byRule["github-pat"], 2);
|
|
assert.equal(result.byRule["aws-access-key"], 1);
|
|
});
|
|
|
|
test("parseGitleaksJson: RuleID ausente usa 'unknown'", () => {
|
|
const finding = {
|
|
Description: "Some secret",
|
|
StartLine: 1,
|
|
File: "src/x.ts",
|
|
// sem RuleID
|
|
};
|
|
const result = parseGitleaksJson([finding]);
|
|
assert.equal(result.findingCount, 1);
|
|
assert.equal(result.byRule["unknown"], 1);
|
|
});
|
|
|
|
test("parseGitleaksJson: suporta campo ruleId (camelCase) como fallback", () => {
|
|
const finding = {
|
|
ruleId: "lowercase-rule",
|
|
File: "src/x.ts",
|
|
Description: "test",
|
|
};
|
|
const result = parseGitleaksJson([finding]);
|
|
assert.equal(result.byRule["lowercase-rule"], 1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — agrupamento por arquivo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: agrupa por File em byFile", () => {
|
|
const findings = [
|
|
makeFinding({ file: "src/config.ts", ruleId: "github-pat" }),
|
|
makeFinding({ file: "src/config.ts", ruleId: "aws-access-key" }), // mesmo arquivo
|
|
makeFinding({ file: "tests/fixtures/token.ts", ruleId: "github-pat" }),
|
|
];
|
|
const result = parseGitleaksJson(findings);
|
|
assert.equal(result.byFile["src/config.ts"], 2);
|
|
assert.equal(result.byFile["tests/fixtures/token.ts"], 1);
|
|
});
|
|
|
|
test("parseGitleaksJson: File ausente usa 'unknown' em byFile", () => {
|
|
const finding = {
|
|
RuleID: "github-pat",
|
|
Description: "Token",
|
|
StartLine: 1,
|
|
// sem File
|
|
};
|
|
const result = parseGitleaksJson([finding]);
|
|
assert.equal(result.byFile["unknown"], 1);
|
|
});
|
|
|
|
test("parseGitleaksJson: suporta campo file (camelCase) como fallback", () => {
|
|
const finding = {
|
|
RuleID: "generic-api-key",
|
|
file: "src/config.js",
|
|
Description: "test",
|
|
};
|
|
const result = parseGitleaksJson([finding]);
|
|
assert.equal(result.byFile["src/config.js"], 1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — entradas inválidas dentro do array
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: entradas null dentro do array são ignoradas", () => {
|
|
const findings = [makeFinding(), null, makeFinding({ ruleId: "aws-access-key" })] as (ReturnType<
|
|
typeof makeFinding
|
|
> | null)[];
|
|
const result = parseGitleaksJson(findings as unknown as ReturnType<typeof makeFinding>[]);
|
|
assert.equal(result.findingCount, 2, "null entries should be skipped");
|
|
});
|
|
|
|
test("parseGitleaksJson: entradas primitivas dentro do array são ignoradas", () => {
|
|
const findings = [
|
|
makeFinding(),
|
|
"string-entry",
|
|
42,
|
|
makeFinding({ ruleId: "aws-access-key" }),
|
|
] as unknown[];
|
|
const result = parseGitleaksJson(findings as ReturnType<typeof makeFinding>[]);
|
|
assert.equal(result.findingCount, 2, "primitive entries should be skipped");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — casos de borda do RuleID PascalCase
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: RuleID PascalCase como emitido pelo gitleaks", () => {
|
|
// gitleaks emite RuleID com PascalCase nos campos
|
|
const finding = {
|
|
RuleID: "github-fine-grained-pat",
|
|
File: "config/auth.yaml",
|
|
Description: "Fine-grained PAT",
|
|
StartLine: 3,
|
|
};
|
|
const result = parseGitleaksJson([finding]);
|
|
assert.equal(result.byRule["github-fine-grained-pat"], 1);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseGitleaksJson — invariantes estruturais
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("parseGitleaksJson: findingCount == soma de todos os byRule values", () => {
|
|
const findings = [
|
|
makeFinding({ ruleId: "a" }),
|
|
makeFinding({ ruleId: "b" }),
|
|
makeFinding({ ruleId: "a" }),
|
|
makeFinding({ ruleId: "c" }),
|
|
];
|
|
const result = parseGitleaksJson(findings);
|
|
const sumByRule = Object.values(result.byRule).reduce((s, n) => s + n, 0);
|
|
assert.equal(result.findingCount, sumByRule, "findingCount must equal sum of byRule counts");
|
|
});
|
|
|
|
test("parseGitleaksJson: findingCount == soma de todos os byFile values", () => {
|
|
const findings = [
|
|
makeFinding({ file: "src/a.ts" }),
|
|
makeFinding({ file: "src/b.ts" }),
|
|
makeFinding({ file: "src/a.ts" }),
|
|
];
|
|
const result = parseGitleaksJson(findings);
|
|
const sumByFile = Object.values(result.byFile).reduce((s, n) => s + n, 0);
|
|
assert.equal(result.findingCount, sumByFile, "findingCount must equal sum of byFile counts");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// evaluateSecretsRatchet — ratchet direction:down (Etapa 2: flip to blocking)
|
|
// Regression when measured > baseline; baseline=3 → 4+ findings block, 3 passes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("evaluateSecretsRatchet: medida == baseline passa (3 vs 3)", () => {
|
|
const r = evaluate(3, 3);
|
|
assert.equal(r.regressed, false);
|
|
assert.equal(r.improved, false);
|
|
});
|
|
|
|
test("evaluateSecretsRatchet: uma a mais que o baseline é regressão (4 vs 3)", () => {
|
|
const r = evaluate(4, 3);
|
|
assert.equal(r.regressed, true, "a single new secret finding must block");
|
|
assert.equal(r.improved, false);
|
|
});
|
|
|
|
test("evaluateSecretsRatchet: menos que o baseline é melhoria", () => {
|
|
const r = evaluate(1, 3);
|
|
assert.equal(r.regressed, false);
|
|
assert.equal(r.improved, true);
|
|
});
|
|
|
|
test("evaluateSecretsRatchet: zero contra baseline não-zero é melhoria máxima", () => {
|
|
const r = evaluate(0, 5);
|
|
assert.equal(r.regressed, false);
|
|
assert.equal(r.improved, true);
|
|
});
|
|
|
|
test("evaluateSecretsRatchet: comparação inteira estrita — qualquer aumento regride", () => {
|
|
assert.equal(evaluate(6, 5).regressed, true);
|
|
assert.equal(evaluate(5, 5).regressed, false);
|
|
assert.equal(evaluate(4, 5).regressed, false);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readBaselineSecretsValue — leitura tolerante do quality-baseline.json
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function withTmpBaseline(content: string | null, fn: (p: string) => void) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-baseline-"));
|
|
const p = path.join(dir, "quality-baseline.json");
|
|
if (content !== null) fs.writeFileSync(p, content);
|
|
try {
|
|
fn(p);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("readBaselineSecretsValue: lê metrics.secretFindings.value", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: { secretFindings: { value: 3 } } }), (p) => {
|
|
assert.equal(readBaseline(p), 3);
|
|
});
|
|
});
|
|
|
|
test("readBaselineSecretsValue: arquivo ausente retorna null (SKIP gracioso)", () => {
|
|
assert.equal(readBaseline("/tmp/does-not-exist-99999/quality-baseline.json"), null);
|
|
});
|
|
|
|
test("readBaselineSecretsValue: métrica ausente retorna null", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: {} }), (p) => {
|
|
assert.equal(readBaseline(p), null);
|
|
});
|
|
});
|
|
|
|
test("readBaselineSecretsValue: value não-numérico retorna null", () => {
|
|
withTmpBaseline(JSON.stringify({ metrics: { secretFindings: { value: "3" } } }), (p) => {
|
|
assert.equal(readBaseline(p), null);
|
|
});
|
|
});
|
|
|
|
test("readBaselineSecretsValue: JSON inválido retorna null (não lança)", () => {
|
|
withTmpBaseline("{ not valid json", (p) => {
|
|
assert.equal(readBaseline(p), null);
|
|
});
|
|
});
|