Commit 629efd36ed

629efd36edad5b593806dc68dc6a17287c880280

parent: 7b6eae1acc

Unsigned

cmc <hello@cleberg.net> · 2026-07-20 15:16 UTC

Address SonarCloud maintainability findings

Clears the 11 low-severity nits and the medium-severity regex finding.
The high-severity router complexity is left as-is for now.

- base64UrlEncode's `=+$` is replaced with a bounded `={1,2}$`. Base64
  padding is never longer than two characters, so the unbounded
  quantifier only bought super-linear backtracking (S8786).
- global single-character `replace` calls become `replaceAll` (S7781),
  `charCodeAt`/`fromCharCode` become `codePointAt`/`fromCodePoint`
  (S7758), and `parseInt` becomes `Number.parseInt` (S7773).
- `86400_000` was grouped wrongly; it is `86_400_000` (S7749).

These sit in session signing, CSV escaping, and GitHub App JWT
key conversion, so they were checked for equivalence rather than
assumed: old and new implementations produce identical output across
3,010 inputs including the atob round-trip, byte handling matches for
all 256 values, and createAppJwt still signs a valid JWT from a PKCS#1
key.

Also adds SECURITY.md, which sets expectations for a hosted App —
one supported version, private reporting, and an explicit note that a
repository lacking branch protection is the product working rather
than a vulnerability.

Layout: unified · split

src/auth.ts +4 −2
@@ -94,11 +94,13 @@ export async function fetchUserInstallationIds(userAccessToken: string): Promise
9494}
9595
9696function base64UrlEncode(input: string): string {
97 return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
97 // Padding is bounded to two characters, so the quantifier is too — an
98 // unbounded `=+$` backtracks super-linearly.
99 return btoa(input).replaceAll("+", "-").replaceAll("/", "_").replace(/={1,2}$/, "");
98100}
99101
100102function base64UrlDecode(input: string): string {
101 const padded = input.replace(/-/g, "+").replace(/_/g, "/");
103 const padded = input.replaceAll("-", "+").replaceAll("_", "/");
102104 const padding = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
103105 return atob(padded + padding);
104106}
src/crypto-utils.ts +1 −1
@@ -1,7 +1,7 @@
11export function hexToBytes(hex: string): Uint8Array {
22 const bytes = new Uint8Array(hex.length / 2);
33 for (let i = 0; i < bytes.length; i++) {
4 bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
4 bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
55 }
66 return bytes;
77}
src/exporter.ts +1 −1
@@ -89,7 +89,7 @@ export function renderCsv(rows: EvidenceRow[]): string {
8989function csvEscape(value: string | number): string {
9090 const str = String(value);
9191 if (/[",\r\n]/.test(str)) {
92 return `"${str.replace(/"/g, '""')}"`;
92 return `"${str.replaceAll('"', '""')}"`;
9393 }
9494 return str;
9595}
src/github-app.ts +2 −2
@@ -9,7 +9,7 @@ const GITHUB_API = "https://api.github.com";
99// key they downloaded.
1010function pkcs1PemToPkcs8Pem(pem: string): string {
1111 const base64 = pem.replace(/-----(BEGIN|END) RSA PRIVATE KEY-----/g, "").replace(/\s/g, "");
12 const pkcs1 = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
12 const pkcs1 = Uint8Array.from(atob(base64), (c) => c.codePointAt(0) ?? 0);
1313
1414 const derLength = (length: number): number[] => {
1515 if (length < 0x80) return [length];
@@ -30,7 +30,7 @@ function pkcs1PemToPkcs8Pem(pem: string): string {
3030 ]);
3131
3232 let binary = "";
33 for (const byte of pkcs8) binary += String.fromCharCode(byte);
33 for (const byte of pkcs8) binary += String.fromCodePoint(byte);
3434 return `-----BEGIN PRIVATE KEY-----\n${btoa(binary)}\n-----END PRIVATE KEY-----`;
3535}
3636
src/index.ts +3 −3
@@ -385,8 +385,8 @@ interface CleanupResult {
385385// a no-op most of the time.
386386async function runRetentionCleanup(env: Env): Promise<CleanupResult> {
387387 const now = Date.now();
388 const snapshotCutoff = new Date(now - SNAPSHOT_RETENTION_DAYS * 86400_000).toISOString();
389 const exportCutoff = new Date(now - EXPORT_RETENTION_DAYS * 86400_000).toISOString();
388 const snapshotCutoff = new Date(now - SNAPSHOT_RETENTION_DAYS * 86_400_000).toISOString();
389 const exportCutoff = new Date(now - EXPORT_RETENTION_DAYS * 86_400_000).toISOString();
390390
391391 // Delete expired export R2 objects first (their keys live in the rows).
392392 const { results: expiredExports } = await env.DB.prepare(
@@ -567,7 +567,7 @@ async function handleAccessReview(request: Request, env: Env): Promise<Response>
567567 const since =
568568 requested && !Number.isNaN(requested.getTime())
569569 ? requested.toISOString()
570 : new Date(Date.now() - 30 * 86400_000).toISOString();
570 : new Date(Date.now() - 30 * 86_400_000).toISOString();
571571
572572 const [diff, orgRow, installations] = await Promise.all([
573573 buildAccessDiff(env.DB, session.installationId, since),