File size: 14,409 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
#!/usr/bin/env node
// scripts/check/check-codeql-ratchet.mjs
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
//
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
//
// Saída (stdout):
//   codeqlAlerts=N        — contagem de alertas CodeQL abertos, não-dismissed
//   codeqlAlerts=SKIP reason=binary-absent   — `gh` não está no PATH
//   codeqlAlerts=SKIP reason=no-auth         — `gh` presente mas sem autenticação
//   codeqlAlerts=SKIP reason=api-error:<code>  — erro da API GitHub
//
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
//
// Uso:
//   node scripts/check/check-codeql-ratchet.mjs
//   node scripts/check/check-codeql-ratchet.mjs --json    # imprime array de alertas
//   node scripts/check/check-codeql-ratchet.mjs --quiet   # suprime logs de diagnóstico
//   node scripts/check/check-codeql-ratchet.mjs --update  # ratcheta o baseline (queda)
//   node scripts/check/check-codeql-ratchet.mjs --advisory  # nunca falha (modo coletor)

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
const ADVISORY = process.argv.includes("--advisory");

const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
  process.argv.includes("--baseline")
    ? process.argv[process.argv.indexOf("--baseline") + 1]
    : path.join(ROOT, "config/quality/quality-baseline.json")
);

// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------

/**
 * Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
 *
 * A GitHub API /code-scanning/alerts retorna um array de:
 * {
 *   number: number,
 *   state: "open" | "dismissed" | "fixed",
 *   dismissed_reason: string | null,
 *   dismissed_at: string | null,
 *   tool: { name: string, ... },
 *   rule: { id: string, severity: string, security_severity_level?: string, ... },
 *   ...
 * }
 *
 * Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
 * Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
 * Alertas de outras ferramentas (ex: Semgrep) são ignorados.
 *
 * @param {Array|null} alerts - Array de alertas da API GitHub
 * @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
 */
export function parseCodeQLAlerts(alerts) {
  if (!Array.isArray(alerts)) {
    return { alertCount: 0, bySeverity: {}, byRule: {} };
  }

  let alertCount = 0;
  const bySeverity = {};
  const byRule = {};

  for (const alert of alerts) {
    // Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
    const toolName = alert?.tool?.name ?? "";
    if (!toolName.toLowerCase().includes("codeql")) continue;

    // Hard Rule #14: alertas dismissed não contam
    if (alert.state === "dismissed") continue;

    // Só alertas abertos
    if (alert.state !== "open") continue;

    alertCount++;

    // Coletar por severidade (security_severity_level ou severity da rule)
    const severity = (
      alert?.rule?.security_severity_level ??
      alert?.rule?.severity ??
      "unknown"
    ).toLowerCase();
    bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;

    // Coletar por rule ID
    const ruleId = alert?.rule?.id ?? "unknown";
    byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
  }

  return { alertCount, bySeverity, byRule };
}

/**
 * Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
 * Direction: down (a contagem só pode CAIR — mais alertas = regressão).
 *
 * Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
 *
 * @param {number} current  - Contagem de alertas medida agora.
 * @param {number} baseline - Contagem congelada em quality-baseline.json.
 * @returns {{ regressed: boolean, improved: boolean }}
 */
export function evaluateCodeqlRatchet(current, baseline) {
  return {
    regressed: current > baseline,
    improved: current < baseline,
  };
}

// ---------------------------------------------------------------------------
// Repository detection
// ---------------------------------------------------------------------------

/**
 * Detecta o owner/repo do repositório atual usando `gh repo view`.
 * Retorna null se `gh` não estiver disponível ou não autenticado.
 *
 * @param {string} ghBin - Caminho para o binário gh
 * @returns {string|null} "owner/repo" ou null
 */
export function detectRepo(ghBin) {
  try {
    const stdout = execFileSync(
      ghBin,
      ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
      {
        encoding: "utf8",
        timeout: 15_000,
      }
    );
    return stdout.trim() || null;
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------

/**
 * Detecta se o binário `gh` está disponível no PATH.
 * Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
 *
 * @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
 */
export function findGhCli() {
  try {
    const result = spawnSync("which", ["gh"], {
      encoding: "utf8",
      timeout: 5_000,
    });
    if (result.status === 0) {
      return result.stdout.trim();
    }
  } catch {
    // which não disponível
  }

  // Fallback: tentar executar diretamente para verificar ENOENT
  try {
    const result = spawnSync("gh", ["--version"], {
      encoding: "utf8",
      timeout: 5_000,
    });
    if (result.error?.code === "ENOENT") return null;
    if (result.status !== null) return "gh"; // found in PATH
  } catch {
    // noop
  }

  return null;
}

// ---------------------------------------------------------------------------
// API caller
// ---------------------------------------------------------------------------

/**
 * Busca alertas CodeQL abertos via `gh api`.
 * Pagina automaticamente (GitHub retorna max 100 por página).
 *
 * @param {string} ghBin - Caminho para o binário gh
 * @param {string} repo  - "owner/repo"
 * @returns {Array} Array de alertas
 */
function fetchCodeQLAlerts(ghBin, repo) {
  const allAlerts = [];
  let page = 1;
  const perPage = 100;

  while (true) {
    const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;

    if (!QUIET) {
      process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
    }

    let stdout;
    try {
      stdout = execFileSync(ghBin, ["api", endpoint], {
        encoding: "utf8",
        timeout: 30_000,
        maxBuffer: 16 * 1024 * 1024,
      });
    } catch (err) {
      const errMsg = String(err.stderr ?? err.message ?? "");

      // Sem autenticação
      if (
        errMsg.includes("authentication") ||
        errMsg.includes("401") ||
        errMsg.includes("not logged")
      ) {
        return { error: "no-auth", message: errMsg };
      }

      // Rate limit ou outro erro HTTP
      const codeMatch = /HTTP (\d{3})/.exec(errMsg);
      const code = codeMatch ? codeMatch[1] : "unknown";
      return { error: `api-error:${code}`, message: errMsg };
    }

    let page_alerts;
    try {
      page_alerts = JSON.parse(stdout);
    } catch (parseErr) {
      // A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
      // regression. A blocking gate must never red on it — return the same
      // {error,message} shape the caller already maps to a graceful SKIP (exit 0).
      return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
    }

    // A API retorna null quando não há mais páginas (ou array vazio)
    if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;

    allAlerts.push(...page_alerts);

    // Se retornou menos que perPage, chegamos à última página
    if (page_alerts.length < perPage) break;

    page++;
  }

  return allAlerts;
}

// ---------------------------------------------------------------------------
// Baseline
// ---------------------------------------------------------------------------

/**
 * Lê metrics.codeqlAlerts.value do quality-baseline.json.
 * Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
 * sem baseline não há ratchet, só emissão da contagem).
 *
 * @returns {number|null}
 */
function readBaselineCodeqlValue() {
  if (!fs.existsSync(BASELINE_PATH)) return null;
  let baselineJson;
  try {
    baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
  } catch {
    return null;
  }
  const metric = baselineJson?.metrics?.codeqlAlerts;
  if (!metric || typeof metric.value !== "number") return null;
  return metric.value;
}

/**
 * Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
 * Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
 * --advisory. Ratcheta o baseline com --update quando a contagem cai.
 *
 * Exported for unit testing (drives o efeito em process.exitCode).
 *
 * @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
 */
export function applyRatchet(alertCount) {
  const baselineValue = readBaselineCodeqlValue();

  // Sem baseline → modo coletor puro (emite a contagem, não falha).
  if (baselineValue === null) {
    if (!QUIET) {
      process.stderr.write(
        "[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
      );
    }
    process.exitCode = 0;
    return;
  }

  const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);

  if (UPDATE && improved) {
    const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
    baselineJson.metrics.codeqlAlerts.value = alertCount;
    fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
    console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
  }

  if (regressed && !ADVISORY) {
    process.stderr.write(
      `[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
        "  → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
        "    'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
    );
    process.exitCode = 1;
    return;
  }

  if (!QUIET) {
    const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
    process.stderr.write(
      `[codeql-ratchet] ${verdict}${alertCount} alertas (baseline ${baselineValue})\n`
    );
  }
  process.exitCode = 0;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

function main() {
  const ghBin = findGhCli();

  if (!ghBin) {
    console.log("codeqlAlerts=SKIP reason=binary-absent");
    if (!QUIET) {
      process.stderr.write(
        "[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
          "[codeql-ratchet] Instale via: https://cli.github.com/\n" +
          "[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
      );
    }
    process.exitCode = 0;
    return;
  }

  // Detectar repositório
  const repo = detectRepo(ghBin);
  if (!repo) {
    console.log("codeqlAlerts=SKIP reason=no-repo");
    if (!QUIET) {
      process.stderr.write(
        "[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
          "[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
      );
    }
    process.exitCode = 0;
    return;
  }

  if (!QUIET) {
    process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
  }

  // Buscar alertas
  const result = fetchCodeQLAlerts(ghBin, repo);

  // Tratar erros da API com skip gracioso
  if (!Array.isArray(result)) {
    const { error, message } = result;
    console.log(`codeqlAlerts=SKIP reason=${error}`);
    if (!QUIET) {
      process.stderr.write(
        `[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
      );
    }
    process.exitCode = 0;
    return;
  }

  if (PRINT_JSON) {
    process.stdout.write(JSON.stringify(result, null, 2) + "\n");
    return;
  }

  const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);

  // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
  console.log(`codeqlAlerts=${alertCount}`);

  if (!QUIET) {
    const severitySummary =
      Object.entries(bySeverity)
        .map(([k, v]) => `${k}=${v}`)
        .join(", ") || "nenhum";
    const topRules =
      Object.entries(byRule)
        .sort(([, a], [, b]) => b - a)
        .slice(0, 5)
        .map(([r, n]) => `${r}(${n})`)
        .join(", ") || "nenhum";

    process.stderr.write(
      `[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
    );
    if (alertCount > 0) {
      process.stderr.write(`[codeql-ratchet]   Por severidade: ${severitySummary}\n`);
      process.stderr.write(`[codeql-ratchet]   Top regras: ${topRules}\n`);
    }
  }

  // Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
  // Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
  applyRatchet(alertCount);
}

if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();