292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { afterEach, describe, it, expect } from "vitest";
|
|
import {
|
|
collectFiles,
|
|
formatFinding,
|
|
isKnownLocalCredential,
|
|
scanText,
|
|
isAllowlistedPath,
|
|
lineHasPlaceholder,
|
|
PATTERNS,
|
|
} from "./verify-secrets.mjs";
|
|
|
|
const temporaryDirectories = [];
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-secrets pattern detection", () => {
|
|
it("detects a GitHub PAT in plain text", () => {
|
|
const sample = `const token = "ghp_AbCdEfGhIjKlMnOpQrStUv0123456789";`;
|
|
const findings = scanText(sample, { filename: "apps/web/secret.ts" });
|
|
expect(findings.length).toBeGreaterThanOrEqual(1);
|
|
expect(findings.some((f) => f.patternId === "github-pat")).toBe(true);
|
|
});
|
|
|
|
it("detects a GitHub fine-grained PAT", () => {
|
|
const sample = `const t = "github_pat_11ABCDEFG0123456789abcdef";`;
|
|
const findings = scanText(sample, { filename: "apps/api/foo.ts" });
|
|
expect(findings.some((f) => f.patternId === "github-fine-grained-pat")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("detects a GitLab PAT", () => {
|
|
const sample = `const t = "glpat-abcDEF1234567890_xyzQR";`;
|
|
const findings = scanText(sample, { filename: "apps/web/x.ts" });
|
|
expect(findings.some((f) => f.patternId === "gitlab-pat")).toBe(true);
|
|
});
|
|
|
|
it("detects common package, model-hosting, Google, and age credentials", () => {
|
|
const samples = [
|
|
["npm-access-token", "npm_abcdefghijklmnopqrstuvwxyz0123456789"],
|
|
["pypi-access-token", "pypi-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"],
|
|
["huggingface-token", "hf_abcdefghijklmnopqrstuvwxyz0123456789"],
|
|
["google-api-key", "AIzaabcdefghijklmnopqrstuvwxyz123456789"],
|
|
["age-secret-key", "AGE-SECRET-KEY-1ABCDEFGHIJKLMNOPQRSTUVWXYZ"],
|
|
];
|
|
for (const [patternId, sample] of samples) {
|
|
expect(scanText(String(sample), { filename: "src/config.ts" }).some((finding) => finding.patternId === patternId)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("detects unquoted secret assignments and credentials embedded in URLs", () => {
|
|
const sample = [
|
|
"CLIENT_SECRET=abcdef0123456789abcdef0123456789",
|
|
"DATABASE_URL=postgresql://service:super-secret-password@db.internal/app",
|
|
].join("\n");
|
|
const findings = scanText(sample, { filename: "infra/app.env" });
|
|
expect(findings.some((finding) => finding.patternId === "generic-high-entropy-assignment")).toBe(true);
|
|
expect(findings.some((finding) => finding.patternId === "embedded-url-credentials")).toBe(true);
|
|
});
|
|
|
|
it("does not classify long source identifiers as unquoted secret values", () => {
|
|
const samples = [
|
|
"const token = existingPortalDomainVerificationTokenHash;",
|
|
"token: E2E_CUSTOMER_01_SECRET_IDENTIFIER,",
|
|
];
|
|
for (const sample of samples) {
|
|
expect(scanText(sample, { filename: "src/domain.ts" })).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it("detects an AWS Access Key ID", () => {
|
|
const sample = `AKIAIOSFODNN7EXAMPLE2`;
|
|
const findings = scanText(sample, { filename: "apps/web/aws.ts" });
|
|
// Note: this sample contains "example" placeholder so it should be skipped.
|
|
expect(findings.length).toBe(0);
|
|
});
|
|
|
|
it("detects a real AWS Access Key ID on a non-placeholder line", () => {
|
|
const sample = `const k = "AKIAZZZZZZZZZZZZZZZZ";`;
|
|
const findings = scanText(sample, { filename: "apps/web/aws.ts" });
|
|
expect(findings.some((f) => f.patternId === "aws-access-key-id")).toBe(true);
|
|
});
|
|
|
|
it("detects payment processor live secret keys but not test keys", () => {
|
|
const live = `const k = "sk_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaa";`;
|
|
const test = `const k = "sk_test_aaaaaaaaaaaaaaaaaaaaaaaaaaaa";`;
|
|
const liveFindings = scanText(live, { filename: "apps/web/s.ts" });
|
|
const testFindings = scanText(test, { filename: "apps/web/s.ts" });
|
|
expect(liveFindings.some((f) => f.patternId === "payment-secret-live")).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
testFindings.some((f) => f.patternId === "payment-secret-live"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("detects payment processor webhook secrets only when the line contains 'live'", () => {
|
|
const withLive = `const k = "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // live prod webhook`;
|
|
const withoutLive = `const k = "whsec_aaaaaaaaaaaaaaaaaaaaaaaaaaaa";`;
|
|
const a = scanText(withLive, { filename: "apps/web/w.ts" });
|
|
const b = scanText(withoutLive, { filename: "apps/web/w.ts" });
|
|
expect(a.some((f) => f.patternId === "payment-webhook-live")).toBe(true);
|
|
expect(b.some((f) => f.patternId === "payment-webhook-live")).toBe(false);
|
|
});
|
|
|
|
it("detects Slack bot tokens", () => {
|
|
const sample = `const t = "xoxb-1234567890-1234567890-1234567890-abcdef1234567890";`;
|
|
const findings = scanText(sample, { filename: "apps/web/slack.ts" });
|
|
expect(findings.some((f) => f.patternId === "slack-bot-token")).toBe(true);
|
|
});
|
|
|
|
it("detects a PEM private key header", () => {
|
|
const sample = `-----BEGIN RSA PRIVATE KEY-----`;
|
|
const findings = scanText(sample, { filename: "apps/web/key.pem" });
|
|
expect(findings.some((f) => f.patternId === "private-key-header")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("detects a JWT-shaped string", () => {
|
|
const sample = `const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";`;
|
|
const findings = scanText(sample, { filename: "apps/web/jwt.ts" });
|
|
expect(findings.some((f) => f.patternId === "jwt-like")).toBe(true);
|
|
});
|
|
|
|
it("detects generic high-entropy assignments to secret-named identifiers", () => {
|
|
const sample = `const password = "abcdef0123456789abcdef0123456789ZZ";`;
|
|
const findings = scanText(sample, { filename: "apps/web/pw.ts" });
|
|
expect(
|
|
findings.some((f) => f.patternId === "generic-high-entropy-assignment"),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("verify-secrets placeholder false-positive suppression", () => {
|
|
it("ignores lines containing 'placeholder'", () => {
|
|
const sample = `const token = "ghp_ZZZZZZZZZZZZZZZZZZZZ"; // placeholder only`;
|
|
expect(scanText(sample, { filename: "x.ts" })).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores lines containing 'example'", () => {
|
|
const sample = `# example: AKIAIOSFODNN7EXAMPL1`;
|
|
expect(scanText(sample, { filename: "x.ts" })).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores 'your-' style placeholders", () => {
|
|
const sample = `PAYMENT_KEY=sk_live_your-real-payment-secret-here-1234`;
|
|
expect(scanText(sample, { filename: "x.ts" })).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores angle-bracket placeholders", () => {
|
|
const sample = `TOKEN=ghp_<paste-token-here-with-padding>`;
|
|
expect(scanText(sample, { filename: "x.ts" })).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores shell/JS interpolation", () => {
|
|
const sample = "const k = `ghp_${process.env.GITHUB_TOKEN}aaaaaaaaaaaaaaaaaaaaa`;";
|
|
expect(scanText(sample, { filename: "x.ts" })).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores 'REDACTED' / 'xxxxx' / 'dummy' tokens", () => {
|
|
expect(lineHasPlaceholder("ghp_REDACTED_VALUE")).toBe(true);
|
|
expect(lineHasPlaceholder("AKIAxxxxxYYYYYY")).toBe(true);
|
|
expect(lineHasPlaceholder("token=dummy_value_here")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("verify-secrets allowlist semantics", () => {
|
|
it("treats .env.example as allowlisted", () => {
|
|
const p = path.join(process.cwd(), "apps", "web", ".env.example");
|
|
expect(isAllowlistedPath(p)).toBe(true);
|
|
});
|
|
|
|
it("treats .env.production.example as allowlisted", () => {
|
|
expect(
|
|
isAllowlistedPath("/repo/.env.production.example"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("treats .env.test as allowlisted", () => {
|
|
expect(isAllowlistedPath("/repo/.env.test")).toBe(true);
|
|
});
|
|
|
|
it("treats test/spec/fixture/mock/stub paths as allowlisted", () => {
|
|
expect(isAllowlistedPath("/repo/apps/web/x.test.ts")).toBe(true);
|
|
expect(isAllowlistedPath("/repo/apps/web/x.spec.ts")).toBe(true);
|
|
expect(isAllowlistedPath("/repo/scripts/fixtures/foo.json")).toBe(true);
|
|
expect(isAllowlistedPath("/repo/apps/web/mocks/server.ts")).toBe(true);
|
|
expect(isAllowlistedPath("/repo/apps/web/stubs/foo.ts")).toBe(true);
|
|
});
|
|
|
|
it("does not allowlist incidental substrings that merely contain test words", () => {
|
|
expect(isAllowlistedPath("/repo/apps/web/attestation/source.ts")).toBe(false);
|
|
expect(isAllowlistedPath("/repo/apps/web/contest/source.ts")).toBe(false);
|
|
expect(isAllowlistedPath("/repo/apps/web/test/source.ts")).toBe(true);
|
|
});
|
|
|
|
it("does not allowlist arbitrary source files", () => {
|
|
expect(isAllowlistedPath("/repo/apps/web/routes/login.tsx")).toBe(false);
|
|
expect(isAllowlistedPath("/repo/packages/db/src/client.ts")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("verify-secrets registry shape", () => {
|
|
it("exposes all expected pattern ids", () => {
|
|
const ids = PATTERNS.map((p) => p.id);
|
|
expect(ids).toEqual(
|
|
expect.arrayContaining([
|
|
"github-pat",
|
|
"github-fine-grained-pat",
|
|
"gitlab-pat",
|
|
"npm-access-token",
|
|
"pypi-access-token",
|
|
"huggingface-token",
|
|
"google-api-key",
|
|
"aws-access-key-id",
|
|
"payment-secret-live",
|
|
"payment-webhook-live",
|
|
"slack-bot-token",
|
|
"private-key-header",
|
|
"age-secret-key",
|
|
"jwt-like",
|
|
"generic-high-entropy-assignment",
|
|
"embedded-url-credentials",
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("verify-secrets tracked-file coverage", () => {
|
|
it("scans tracked hidden and infrastructure paths but not untracked files", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hivegrid-secrets-"));
|
|
temporaryDirectories.push(root);
|
|
spawnSync("git", ["init", "-q"], { cwd: root });
|
|
for (const relativePath of [".gitea/workflows/ci.yml", "infra/app.env", "runtime/api/index.js"]) {
|
|
const absolutePath = path.join(root, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, "tracked");
|
|
}
|
|
fs.writeFileSync(path.join(root, "untracked.txt"), "not scanned");
|
|
spawnSync("git", ["add", ".gitea/workflows/ci.yml", "infra/app.env", "runtime/api/index.js"], { cwd: root });
|
|
|
|
const relativeFiles = collectFiles(root).map((file) => path.relative(root, file)).sort();
|
|
|
|
expect(relativeFiles).toEqual([".gitea/workflows/ci.yml", "infra/app.env", "runtime/api/index.js"]);
|
|
});
|
|
|
|
it("redacts matched values from scanner output", () => {
|
|
const secret = "ghp_AbCdEfGhIjKlMnOpQrStUv0123456789";
|
|
const output = formatFinding(
|
|
{ file: "/repo/app.ts", line: 1, patternId: "github-pat", match: secret },
|
|
"/repo",
|
|
);
|
|
expect(output).not.toContain(secret);
|
|
expect(output).toContain("redacted length=");
|
|
});
|
|
|
|
it("recognizes only the canonical loopback Postgres test credential", () => {
|
|
const finding = {
|
|
patternId: "embedded-url-credentials",
|
|
match: "postgresql://postgres:postgres@localhost:5432/hivegrid",
|
|
};
|
|
expect(isKnownLocalCredential(finding)).toBe(true);
|
|
expect(
|
|
isKnownLocalCredential({
|
|
...finding,
|
|
match: "postgresql://hivegrid:hivegrid@127.0.0.1:5432/hivegrid",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isKnownLocalCredential({
|
|
...finding,
|
|
match: "postgresql://postgres:not-the-test-value@localhost:5432/hivegrid",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
isKnownLocalCredential({
|
|
...finding,
|
|
match: "postgresql://postgres:postgres@db.internal:5432/hivegrid",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|