mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
199 lines
7.5 KiB
TypeScript
199 lines
7.5 KiB
TypeScript
import { webcrypto } from "node:crypto";
|
|
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
downloadPublicCertificateFile,
|
|
generateProwlerCertificate,
|
|
} from "./azure-cert-generator";
|
|
|
|
// jsdom exposes `globalThis.crypto` but historically without a working
|
|
// `subtle` implementation. Node 20+ ships one on `node:crypto.webcrypto`
|
|
// that satisfies both @peculiar/x509 and our helper. Bind it once so the
|
|
// module-level `cryptoProvider.set(...)` inside the SUT resolves it.
|
|
beforeAll(() => {
|
|
if (!globalThis.crypto || !globalThis.crypto.subtle) {
|
|
Object.defineProperty(globalThis, "crypto", {
|
|
value: webcrypto,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("generateProwlerCertificate", () => {
|
|
it("produces a certificate + private-key bundle that round-trips through PEM parsers", async () => {
|
|
// Given / When
|
|
const result = await generateProwlerCertificate({
|
|
// Shrink the modulus so the test finishes in <2s under CI without
|
|
// giving up any of the code paths the helper touches at 4096 bits.
|
|
modulusLength: 2048,
|
|
commonName: "prowler-test",
|
|
validityDays: 30,
|
|
});
|
|
|
|
// Then — DER base64 of the public certificate decodes to a byte array
|
|
// that starts with the ASN.1 SEQUENCE tag (0x30). A stray bug that
|
|
// returned PEM instead of DER, or double-encoded the base64, would trip
|
|
// this straight away.
|
|
const publicDer = Uint8Array.from(
|
|
atob(result.publicCertificateBase64Der),
|
|
(c) => c.charCodeAt(0),
|
|
);
|
|
expect(publicDer[0]).toBe(0x30);
|
|
expect(publicDer.byteLength).toBeGreaterThan(500);
|
|
|
|
// The private-key bundle decodes to a UTF-8 PEM string containing both
|
|
// markers, in the order azure-identity expects (cert first, key second).
|
|
const bundlePem = new TextDecoder().decode(
|
|
Uint8Array.from(atob(result.privateKeyBundleBase64Pem), (c) =>
|
|
c.charCodeAt(0),
|
|
),
|
|
);
|
|
const certIdx = bundlePem.indexOf("-----BEGIN CERTIFICATE-----");
|
|
const keyIdx = bundlePem.indexOf("-----BEGIN PRIVATE KEY-----");
|
|
expect(certIdx).toBeGreaterThanOrEqual(0);
|
|
expect(keyIdx).toBeGreaterThan(certIdx);
|
|
expect(bundlePem).toContain("-----END CERTIFICATE-----");
|
|
expect(bundlePem).toContain("-----END PRIVATE KEY-----");
|
|
|
|
// Validity window respects the injected days and is a real ISO 8601
|
|
// timestamp.
|
|
const notBefore = new Date(result.notBefore);
|
|
const notAfter = new Date(result.notAfter);
|
|
expect(notBefore.getTime()).toBeLessThan(notAfter.getTime());
|
|
const days = (notAfter.getTime() - notBefore.getTime()) / 86_400_000;
|
|
expect(days).toBeCloseTo(30, 0);
|
|
});
|
|
|
|
it("returns the correct SHA-1 thumbprint format expected by Entra ID", async () => {
|
|
// Given / When
|
|
const result = await generateProwlerCertificate({ modulusLength: 2048 });
|
|
|
|
// Then — 40 hex chars, uppercase, no separators.
|
|
expect(result.thumbprintHex).toMatch(/^[0-9A-F]{40}$/);
|
|
});
|
|
|
|
it("throws a friendly error when SubtleCrypto is unavailable", async () => {
|
|
// Given the browser doesn't expose subtle (insecure origin, ancient
|
|
// browser, some sandboxes).
|
|
const originalCrypto = globalThis.crypto;
|
|
Object.defineProperty(globalThis, "crypto", {
|
|
value: {},
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
|
|
// When / Then
|
|
await expect(generateProwlerCertificate()).rejects.toThrow(
|
|
/Web Crypto API is not available/i,
|
|
);
|
|
|
|
// Cleanup
|
|
Object.defineProperty(globalThis, "crypto", {
|
|
value: originalCrypto,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
});
|
|
|
|
it("emits a leaf cert with BasicConstraints(cA=false) and KeyUsage(digitalSignature)", async () => {
|
|
// Guardrail: audit tooling and strict CA validators flag self-signed
|
|
// leaves that omit these extensions. A future edit that drops them
|
|
// from the `extensions:` array must not slip past review.
|
|
const {
|
|
BasicConstraintsExtension,
|
|
KeyUsageFlags,
|
|
KeyUsagesExtension,
|
|
X509Certificate,
|
|
} = await import("@peculiar/x509");
|
|
|
|
const result = await generateProwlerCertificate({ modulusLength: 2048 });
|
|
|
|
const publicDer = Uint8Array.from(
|
|
atob(result.publicCertificateBase64Der),
|
|
(c) => c.charCodeAt(0),
|
|
);
|
|
const cert = new X509Certificate(publicDer);
|
|
|
|
const basicConstraints = cert.getExtension(BasicConstraintsExtension);
|
|
expect(basicConstraints).toBeDefined();
|
|
expect(basicConstraints!.ca).toBe(false);
|
|
|
|
const keyUsages = cert.getExtension(KeyUsagesExtension);
|
|
expect(keyUsages).toBeDefined();
|
|
// `usages` is a bitmask — verify the digitalSignature bit is set.
|
|
expect(keyUsages!.usages & KeyUsageFlags.digitalSignature).toBe(
|
|
KeyUsageFlags.digitalSignature,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("downloadPublicCertificateFile", () => {
|
|
const originalCreateElement = document.createElement.bind(document);
|
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
|
|
afterEach(() => {
|
|
document.createElement = originalCreateElement;
|
|
URL.createObjectURL = originalCreateObjectURL;
|
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
});
|
|
|
|
it("triggers an anchor click with the right href and filename, then revokes the blob URL", () => {
|
|
// Given
|
|
const clickSpy = vi.fn();
|
|
const objectUrl = "blob:mock/prowler-cert";
|
|
URL.createObjectURL = vi.fn(() => objectUrl);
|
|
const revokeSpy = vi.fn();
|
|
URL.revokeObjectURL = revokeSpy;
|
|
|
|
// The anchor spy is a real HTMLAnchorElement so `document.body.appendChild`
|
|
// and `removeChild` accept it; we only intercept the `click` method.
|
|
const realAnchor = originalCreateElement("a");
|
|
realAnchor.click = clickSpy;
|
|
document.createElement = vi.fn((tag: string) => {
|
|
if (tag === "a") return realAnchor;
|
|
return originalCreateElement(tag);
|
|
}) as typeof document.createElement;
|
|
|
|
// When — pass a valid base64 payload (the helper now decodes it back to
|
|
// raw DER bytes so the download is a `.cer` file the Portal accepts).
|
|
// `MII=` is short but valid base64 that decodes to bytes [0x30, 0x82],
|
|
// matching the ASN.1 SEQUENCE tag prefix that real X.509 DER starts
|
|
// with — good enough to prove the decode path without pulling in a
|
|
// real cert.
|
|
downloadPublicCertificateFile("MII=", "prowler-cert.cer");
|
|
|
|
// Then
|
|
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
|
expect(realAnchor.href).toContain(objectUrl);
|
|
expect(realAnchor.download).toBe("prowler-cert.cer");
|
|
// Revoked to avoid leaking the blob URL for the tab's lifetime.
|
|
expect(revokeSpy).toHaveBeenCalledWith(objectUrl);
|
|
});
|
|
|
|
it("defaults the filename when the caller omits it", () => {
|
|
// Given
|
|
URL.createObjectURL = vi.fn(() => "blob:mock");
|
|
URL.revokeObjectURL = vi.fn();
|
|
const realAnchor = originalCreateElement("a");
|
|
realAnchor.click = vi.fn();
|
|
document.createElement = vi.fn((tag: string) => {
|
|
if (tag === "a") return realAnchor;
|
|
return originalCreateElement(tag);
|
|
}) as typeof document.createElement;
|
|
|
|
// When — pass a real base64 payload; the helper now decodes it back to
|
|
// raw DER bytes so the download is a valid `.cer` file the Portal accepts
|
|
// without any manual decoding step. `AA==` decodes to a single 0x00 byte,
|
|
// which is enough to exercise the base64→bytes path without pulling a
|
|
// real certificate into the test.
|
|
downloadPublicCertificateFile("AA==");
|
|
|
|
// Then
|
|
expect(realAnchor.download).toBe("prowler-cert.cer");
|
|
});
|
|
});
|