# Haxset Security Scanner — setup: https://app.haxset.com/ci/setup
# Needs CI/CD variables HAXSET_SCANNER_TOKEN and HAXSET_GITLAB_TOKEN (Masked, Protected off).
# Jobs run in the `test` stage. Triage the findings in Haxset — every note links to its page.

.haxset_base:
  stage: test
  image: $HAXSET_IMAGE
  interruptible: false
  variables:
    HAXSET_IMAGE: "node:20"
    GIT_DEPTH: "0"
    HAXSET_ENDPOINT: "https://app.haxset.com/api/ci/scan"
    HAXSET_POLL_MINUTES: "60"
    HAXSET_START_RETRIES: "4"
    # haxset-sast only: thorough | fast, and extra comma-separated report recipients.
    HAXSET_SCAN_MODE: "thorough"
    HAXSET_NOTIFY: ""
    # haxset-sast only: a repeat run re-checks the previous one and searches changed files;
    # "true" searches every file (decisions are still kept).
    HAXSET_FULL_SCAN: "false"
    # "false" = written remediation only, no committable suggestions on the diff.
    HAXSET_SUGGESTIONS: "true"
  script:
    - node "$CI_PROJECT_DIR/.haxset-scanner.js"
  before_script:
    - |
      cat > "$CI_PROJECT_DIR/.haxset-scanner.js" <<'HAXSET_EOF'
      'use strict';
      // execFileSync ONLY — never execSync. Everything this script shells out to
      // takes values it does not control (a branch name, a job variable), and an
      // argv array is not parsed by a shell.
      const { execFileSync } = require('child_process');
      const fs = require('fs');
      const pathMod = require('path');

      const env = process.env;
      const ENDPOINT = String(env.HAXSET_ENDPOINT || '').replace(/\/+$/, '');
      const REPO_SCAN_ENDPOINT = ENDPOINT.replace(/\/scan$/, '/repo-scan');
      // Platform base, derived from the endpoint so a self-hosted Haxset needs no
      // second variable. Findings are triaged IN the platform (GitLab has no
      // comment-triggered pipelines, so there is no `/haxset fp` equivalent here).
      const APP_BASE = ENDPOINT.replace(/\/api\/ci\/scan$/, '');
      const TOKEN = env.HAXSET_SCANNER_TOKEN || '';
      const GL_TOKEN = env.HAXSET_GITLAB_TOKEN || '';
      const API = String(env.CI_API_V4_URL || '').replace(/\/+$/, '');
      const PROJECT_ID = env.CI_MERGE_REQUEST_PROJECT_ID || env.CI_PROJECT_ID;
      const MR_IID = env.CI_MERGE_REQUEST_IID;
      const COMMAND = String(env.HAXSET_COMMAND || 'auto').toLowerCase();
      const MARKER = '<!-- haxset-security-scanner -->';
      const STICKY_MARKER = '<!-- haxset-sticky -->';
      const MAX_NOTE_PAGES = 20;

      const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
      const log = (m) => console.log('[haxset] ' + m);
      const bail = (m) => { log(m); process.exit(0); };

      async function glFetch(path, opts) {
        return fetch(`${API}${path}`, Object.assign({}, opts, {
          headers: Object.assign({ 'PRIVATE-TOKEN': GL_TOKEN }, (opts && opts.headers) || {}),
        }));
      }

      // Deep link to this merge request's page in the Haxset platform, where every
      // finding — `F#` code, `S#` secrets, `I#` misconfigurations, `D#` dependencies —
      // is triaged. A decision made there is the SAME record a scan reads back, so it
      // applies to this merge request on the next pipeline.
      function platformUrl() {
        const repoPath = env.CI_MERGE_REQUEST_PROJECT_PATH || env.CI_PROJECT_PATH || '';
        if (!APP_BASE || !repoPath || !MR_IID) return '';
        return `${APP_BASE}/ci/pr?repo=${encodeURIComponent(repoPath)}`
          + `&prNumber=${encodeURIComponent(MR_IID)}&provider=gitlab`;
      }

      // Nothing that might carry the CI token may reach an MR note. A note on a
      // public project is world-readable, and Node's child_process errors quote the
      // whole argv back at you ("Command failed: <argv>"), so one failed request
      // would otherwise publish the org's shared token. The token is kept out of
      // argv entirely (see handleFullScan); this is the second line of defence for
      // every string echoed into a note.
      function scrub(s) {
        let out = String(s === undefined || s === null ? '' : s);
        out = out.replace(/Bearer\s+\S+/gi, 'Bearer ***');
        out = out.replace(/haxset_ci_[A-Za-z0-9._~+/=-]+/gi, 'haxset_ci_***');
        if (TOKEN) out = out.split(TOKEN).join('***');
        if (GL_TOKEN) out = out.split(GL_TOKEN).join('***');
        return out;
      }

      function noteBody(md, sticky) {
        const runLink = env.CI_PIPELINE_URL ? ` · [pipeline](${env.CI_PIPELINE_URL})` : '';
        const link = platformUrl();
        const triage = link
          ? `[Triage these findings in Haxset](${link})`
          : 'Triage these findings in the Haxset dashboard';
        const footer = '\n\n---\n'
          + '<sub>' + triage + ' — mark a false positive, accepted risk or confirmed '
          + 'issue; the decision shows up here on the next pipeline. '
          + '**haxset-rescan** re-scan · **haxset-check** verify fixes · '
          + '**haxset-sast** scan the whole repository'
          + runLink + '</sub>';
        return MARKER + (sticky ? '\n' + STICKY_MARKER : '') + '\n' + md + footer;
      }

      function warnNoteFailed(e) {
        console.warn('[haxset] Could not post the MR note: ' + e
          + '\n[haxset] Check that HAXSET_GITLAB_TOKEN is set and has the `api` scope.');
      }

      async function listNotes() {
        const out = [];
        for (let page = 1; page <= MAX_NOTE_PAGES; page++) {
          let res;
          try {
            res = await glFetch(
              `/projects/${PROJECT_ID}/merge_requests/${MR_IID}/notes`
              + `?per_page=100&page=${page}&sort=desc&order_by=updated_at`);
          } catch (e) { break; }
          if (!res.ok) break;
          const batch = await res.json();
          if (!Array.isArray(batch) || batch.length === 0) break;
          out.push(...batch);
          if (!res.headers.get('x-next-page')) break;
        }
        return out;
      }

      async function postNewNote(md, sticky) {
        // A full-repository scan can run on a branch pipeline with no merge request to
        // comment on. That is not an error — the result is emailed and lives in the
        // platform — so posting is simply skipped rather than warned about.
        if (!MR_IID) { log('No merge request on this pipeline — skipping the note.'); return; }
        if (!GL_TOKEN) { warnNoteFailed('HAXSET_GITLAB_TOKEN is not set'); return; }
        try {
          const res = await glFetch(
            `/projects/${PROJECT_ID}/merge_requests/${MR_IID}/notes`,
            { method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ body: noteBody(md, sticky) }) });
          if (!res.ok) warnNoteFailed(`API ${res.status}: ${(await res.text()).slice(0, 200)}`);
        } catch (e) { warnNoteFailed(e.message); }
      }

      async function findStickyNote() {
        // ⚠️ OUR OWN notes only. Notes come back newest-first, so a contributor
        // who posts a note containing the sticky marker becomes the newest match
        // and owns the note every scan result is written into — and with an
        // api-scoped maintainer token the update succeeds, so they can then edit
        // the result to read clean. GitLab never reads the fingerprint marker
        // back (triage is in-platform), so this is defacement rather than triage
        // forgery — but it is the vendor's voice either way.
        let selfId = null;
        try {
          const me = await glFetch('/user');
          if (me.ok) selfId = (await me.json()).id;
        } catch (e) { /* fall through: no note is safer than the wrong note */ }
        const notes = await listNotes();
        return notes.find((n) => n.body && n.body.includes(STICKY_MARKER)
          && n.author && selfId !== null && n.author.id === selfId) || null;
      }

      async function postStickyNote(md) {
        if (!MR_IID) { log('No merge request on this pipeline — skipping the note.'); return; }
        if (!GL_TOKEN) { warnNoteFailed('HAXSET_GITLAB_TOKEN is not set'); return; }
        const existing = await findStickyNote();
        if (!existing) return postNewNote(md, true);
        try {
          const res = await glFetch(
            `/projects/${PROJECT_ID}/merge_requests/${MR_IID}/notes/${existing.id}`,
            { method: 'PUT',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ body: noteBody(md, true) }) });
          if (!res.ok) warnNoteFailed(`API ${res.status}: ${(await res.text()).slice(0, 200)}`);
        } catch (e) { warnNoteFailed(e.message); }
      }

      const isAuto = COMMAND === 'auto';
      const postNote = (md) => (isAuto ? postStickyNote(md) : postNewNote(md));

      function apiHint(status) {
        return status === 402 ? ' — PR scan quota exhausted (ask your Haxset admin to raise the limit)'
          : status === 401 ? ' — invalid or revoked Haxset CI token'
          : status === 403 ? ' — CI scanning is disabled for this account'
          : status === 429 ? ' — rate limited, retry shortly'
          : '';
      }

      // ── One-click fixes ─────────────────────────────────────────────────────
      // A finding the backend could patch carries a `fix` with byte-exact
      // replacement lines. Posted as a DIFF NOTE containing a ```suggestion block,
      // which GitLab renders with an "Apply suggestion" button — the reviewer
      // commits the change from inside the merge request, and this template never
      // needs write access to the repository's code.
      //
      // GitLab's suggestion range syntax is RELATIVE to the anchored line:
      // ```suggestion:-N+M replaces N lines above and M below it. We anchor at the
      // END of the range, so N = (end - start) and M is always 0.
      //
      // Nothing here may fail the job. Every failure degrades to "the summary note
      // still lists the finding with its written remediation", which is what a
      // reviewer got before this feature existed.
      const AI_FIX_LABEL = 'AI-generated fix · verify before merging';
      const AI_FIX_DELETION = '**This suggestion deletes the line(s) above.**';
      // Set HAXSET_SUGGESTIONS=false (CI/CD variable) to receive findings with
      // written remediation only, with no committable suggestions on the diff.
      // Mirrors the GitHub action's `suggestions:` input.
      // '' after trimming counts as OFF, matching the action's config.js — a
      // variable set to whitespace means someone meant to turn it off.
      const SUGGESTIONS_ON = !['false', '0', 'no', 'off', '']
        .includes(String(env.HAXSET_SUGGESTIONS === undefined ? 'true' : env.HAXSET_SUGGESTIONS)
          .trim().toLowerCase());

      // ⚠️ A fixed three-backtick fence is WRONG, and silently so. A fenced block
      // closes at the first line carrying at least as many backticks as the
      // opener, so a replacement line that is itself ``` (routine in a .md file,
      // a Helm template, a docstring, a heredoc) closes the suggestion early.
      // GitLab then applies only the truncated head of the patch — deleting the
      // closing fence and everything after it — while the note still says
      // "✅ Verified". The fence must out-length the content.
      function fenceFor(lines) {
        let longest = 0;
        for (const line of lines || []) {
          const runs = String(line === undefined || line === null ? '' : line).match(/`+/g);
          if (!runs) continue;
          for (const run of runs) longest = Math.max(longest, run.length);
        }
        return '`'.repeat(Math.max(3, longest + 1));
      }

      // GitLab rejects a suggestion spanning more than 100 lines above the anchor.
      // Past it the discussion 400s and the patch is lost, so it is filtered here
      // rather than discovered at post time.
      const GL_MAX_LINES_ABOVE = 100;

      // Neutralize a scan-derived string before interpolating it into a note.
      // Every rendered finding string is model output derived from an untrusted
      // diff and renders ABOVE the hidden `<!-- haxset-findings -->` marker, so an
      // embedded `<!--` either forges a fingerprint mapping or opens an HTML block
      // that swallows the findings above it. Newlines are kept — this is prose.
      const MAX_LISTED_PATHS = 50;
      function pathList(paths) {
        const all = paths || [];
        const shown = all.slice(0, MAX_LISTED_PATHS)
          .map((f) => '`' + safeText(String(f), 200) + '`').join(', ');
        return all.length > MAX_LISTED_PATHS
          ? shown + ' … and ' + (all.length - MAX_LISTED_PATHS) + ' more' : shown;
      }

      function safeText(value, cap) {
        let out = String(value === undefined || value === null ? '' : value)
          .replace(/</g, '&lt;');
        const limit = cap || 8000;
        if (out.length > limit) out = out.slice(0, limit - 1).trimEnd() + '…';
        return out;
      }

      function collectFixes(sections) {
        const out = [];
        for (const s of sections) {
          (s.items || []).forEach((f, idx) => {
            const fix = f && f.fix;
            // An EMPTY replacement_lines is a DELETION, not a missing fix. Both
            // providers express "remove these lines" as an empty suggestion block,
            // and the backend only ever emits one that survived verification, so
            // skipping it here discarded a fix that was already generated and
            // proven. Mirrors collectFixes() in the GitHub action.
            if (!fix || !Array.isArray(fix.replacement_lines)) return;
            if (!f.file_path || !fix.start_line || !fix.end_line) return;
            // An inverted or non-integer range would silently degrade to a
            // SINGLE-line suggestion anchored at end_line — replacing the wrong
            // line rather than being rejected. Mirrors reviewComment() in the
            // GitHub action.
            const start = Number(fix.start_line);
            const end = Number(fix.end_line);
            if (!Number.isInteger(start) || !Number.isInteger(end)) return;
            if (start < 1 || end < start) return;
            if (end - start > GL_MAX_LINES_ABOVE) return;
            out.push({ label: `${s.prefix}${idx + 1}`, finding: f, fix });
          });
        }
        return out;
      }

      // Defence in depth, mirroring safeProse() in the action: `note` is model
      // prose that renders UNFENCED above the suggestion, so a fence inside it
      // becomes a second, wholly unguarded committable suggestion (or swallows
      // the real one). BOTH CommonMark fence characters are neutralized — GitLab
      // detects a suggestion from the rendered info string, and `~~~` opens a
      // fenced block exactly as ``` does.
      function safeProse(text, max) {
        let flat = String(text === undefined || text === null ? '' : text)
          .replace(/\s+/g, ' ').replace(/`/g, "'").replace(/~/g, '-')
          .replace(/</g, '&lt;').trim();
        const cap = max || 300;
        if (flat.length > cap) flat = flat.slice(0, cap - 1).trimEnd() + '…';
        return flat;
      }

      function suggestionNoteBody(entry) {
        const { label, finding, fix } = entry;
        const above = Math.max(0, Number(fix.end_line) - Number(fix.start_line));
        // The TITLE goes through safeProse too. It is model-authored and renders
        // in the note body, so an injected fence here produced a SECOND
        // committable suggestion above the real one, under the ✅ Verified badge,
        // having passed none of the guards.
        const safeTitle = safeProse(finding.title, 200) || 'Security finding';
        const parts = [`**${String(finding.severity || '').toUpperCase()} · \`${label}\`** — ${safeTitle}`];
        if (fix.verified) parts.push('✅ **Verified** — the finding is gone after this change');
        // A deletion's whole content is "these lines go away", so the reviewer must
        // be told BEFORE clicking Apply — an empty suggestion block otherwise reads
        // as a rendering glitch rather than the change itself.
        if (!fix.replacement_lines.length) parts.push(AI_FIX_DELETION);
        if (fix.note) parts.push(safeProse(fix.note));
        // Emitted verbatim: the backend proved these lines byte-match the file, so
        // any reformatting here would break that guarantee. A deletion emits the
        // fence with NO content line — a blank line between the fences would
        // REPLACE the range with an empty line instead of removing it.
        const fence = fenceFor(fix.replacement_lines);
        parts.push(fix.replacement_lines.length
          ? fence + 'suggestion:-' + above + '+0\n'
            + fix.replacement_lines.join('\n') + '\n' + fence
          : fence + 'suggestion:-' + above + '+0\n' + fence);
        parts.push(`<sub>${AI_FIX_LABEL}</sub>`);
        return parts.join('\n\n');
      }

      function fixFencedDiff(entry) {
        const { label, finding, fix } = entry;
        // Escaped: `file_path` is repo content and this renders ABOVE the hidden
        // marker, so a raw `<!--` in a filename swallows it.
        const loc = `${safeText(finding.file_path, 300)}:${fix.start_line}`
          + (fix.end_line > fix.start_line ? `-${fix.end_line}` : '');
        const lines = [].concat(
          (fix.original_lines || []).map((l) => '-' + l),
          (fix.replacement_lines || []).map((l) => '+' + l),
        );
        const fence = fenceFor(lines);
        let out = `<details>\n<summary>🛠️ <code>${label}</code> — suggested fix for <code>${loc}</code>`
          + `${fix.verified ? ' · ✅ verified' : ''}</summary>\n\n`;
        if (fix.note) out += `${safeProse(fix.note)}\n\n`;
        out += fence + 'diff\n' + lines.join('\n') + '\n' + fence + '\n\n';
        out += `<sub>${AI_FIX_LABEL}</sub>\n\n</details>\n\n`;
        return out;
      }

      // The MR's diff_refs — required on every position object. Without all three
      // shas GitLab rejects the discussion, so a failure here means fenced diffs.
      async function mrDiffRefs() {
        if (!GL_TOKEN || !PROJECT_ID || !MR_IID) return null;
        try {
          const r = await glFetch(`/projects/${PROJECT_ID}/merge_requests/${MR_IID}`);
          if (!r.ok) { log(`Could not read MR diff refs (API ${r.status}).`); return null; }
          const refs = (await r.json()).diff_refs || null;
          if (!refs || !refs.base_sha || !refs.head_sha || !refs.start_sha) return null;
          return refs;
        } catch (e) { log('Could not read MR diff refs: ' + e.message); return null; }
      }

      // Posts one discussion per fix. Individually, deliberately: GitLab has no
      // batch endpoint, and one rejected position (a line that moved since the
      // scan) must not take the other suggestions down with it.
      async function postSuggestions(entries, refs) {
        let posted = 0;
        let failed = 0;
        for (const entry of entries) {
          const position = {
            base_sha: refs.base_sha,
            head_sha: refs.head_sha,
            start_sha: refs.start_sha,
            new_path: entry.finding.file_path,
            old_path: entry.finding.file_path,
            new_line: entry.fix.end_line,
            position_type: 'text',
          };
          try {
            const r = await glFetch(
              `/projects/${PROJECT_ID}/merge_requests/${MR_IID}/discussions`,
              {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ body: suggestionNoteBody(entry), position }),
              },
            );
            if (r.ok) { posted += 1; } else {
              failed += 1;
              log(`Skipped a fix suggestion on ${entry.finding.file_path}:${entry.fix.end_line} `
                + `(API ${r.status}). The finding is still listed in the summary note.`);
            }
          } catch (e) {
            failed += 1;
            log(`Skipped a fix suggestion on ${entry.finding.file_path}: ${e.message}`);
          }
        }
        return { posted, failed };
      }

      // The archive goes up in chunks — a session, PUTs of at most `max_chunk_size`,
      // then a `complete` that starts the scan — because Cloudflare answers any request
      // body over 100 MB with its own 413. The token travels ONLY in a fetch header:
      // never argv (readable by every process on the runner, and quoted back in Node's
      // errors, which get posted) and never a file. Each chunk is a Buffer of exactly
      // its length so the request carries a Content-Length, the header the edge decides
      // on; a stream body would go out chunked-encoded, which nobody has measured.
      // Cloudflare does not clock body transfer, but Node gives every request 300 s
      // INCLUDING the body upload, so chunks are sized to ~30 s (see TARGET_CHUNK_SECONDS)
      // rather than sent at the maximum, which would fail every attempt on a slow runner.
      /** Waits before retrying a transient failure of the chunked upload. */
      const BACKOFF_MS = [1000, 2000, 4000, 8000];
      /** Replies that do not move the upload past its furthest point, before giving up. */
      const MAX_STALLS = 5;
      const MIB = 1024 * 1024;
      /**
       * Wall time one chunk should take. Node gives every request 300 s, body upload
       * included (undici's headers timeout), so a 64 MiB chunk dies on a runner under
       * ~1.8 Mbit/s; ~30 s per chunk also amortizes the ~4-5 s each request costs.
       */
      const TARGET_CHUNK_SECONDS = 30;

      /**
       * Upload `zip` to `<base>/sessions` → `/chunk` → `/complete` and return the
       * `complete` reply (`{ok, uuid, url, mode, notified}`). Throws an Error whose
       * message is safe to scrub and post.
       *
       * Chunks start at the server's `chunk_size` and grow, after each stored one, to
       * ~TARGET_CHUNK_SECONDS of the rate that chunk achieved (whole MiB, within
       * [chunk_size, max_chunk_size]); a chunk that fails retryably is re-sent at
       * `chunk_size`, so a slow link never repeats a request that cannot finish.
       *
       * The server is the authority on how many bytes it holds: every chunk reply (200,
       * or 409 when the offset is wrong) carries `received`, and the next chunk starts
       * there — so a lost reply to a chunk it DID store just resyncs. `complete` is
       * idempotent server-side, so a lost reply is retried blindly and never starts a
       * second scan. Network errors, 5xx, 408, 429, a non-JSON reply (an edge HTML
       * page) and a 409 WITHOUT `received` (busy) are retried with backoff.
       */
      async function uploadArchive(base, token, zip, fields) {
        const size = fs.statSync(zip).size;
        // One attempt; `retry` marks a failure worth repeating.
        const send = async (method, url, body) => {
          let status = 0;
          let data = null;
          let text = '';
          try {
            const res = await fetch(url, {
              method,
              headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': typeof body === 'string' ? 'application/json' : 'application/octet-stream',
              },
              body,
            });
            status = res.status;
            text = await res.text();
            const parsed = JSON.parse(text);
            if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed;
          } catch (e) {
            if (!status) text = (e && e.cause && e.cause.message) || (e && e.message) || String(e);
          }
          const busy = status === 409 && data && typeof data.received !== 'number';
          const retry = !data || busy || status >= 500 || status === 408 || status === 429;
          return { status, data, text, retry };
        };
        const gaveUp = (r) => new Error(r.status
          ? `HTTP ${r.status}: ${r.text.slice(0, 200)}` : `network error: ${r.text}`);
        const failure = (r) => {
          const d = r.data.detail !== undefined ? r.data.detail : r.data.error;
          return new Error(`HTTP ${r.status}: ${typeof d === 'string' ? d : JSON.stringify(r.data)}`);
        };
        // `send` with the backoff, for the two small JSON calls.
        const call = async (method, url, body) => {
          for (let attempt = 0; ; attempt++) {
            const r = await send(method, url, body);
            if (!r.retry) return r;
            if (attempt === BACKOFF_MS.length) throw gaveUp(r);
            await sleep(BACKOFF_MS[attempt]);
          }
        };

        const s = await call('POST', `${base}/sessions`, JSON.stringify({ size }));
        if (s.status < 200 || s.status > 299) throw failure(s);
        const sid = s.data.session_id;
        const start = s.data.chunk_size;
        const max = Math.max(start, s.data.max_chunk_size || start);
        if (typeof sid !== 'string' || !sid || !Number.isInteger(start) || start <= 0
            || !Number.isInteger(max)) throw failure(s);

        const fd = fs.openSync(zip, 'r');
        try {
          let offset = 0;
          let next = start;
          let high = 0;
          let stalls = 0;
          let attempt = 0;
          for (;;) {
            let r;
            if (offset < size) {
              const buf = Buffer.alloc(Math.min(next, size - offset));
              if (fs.readSync(fd, buf, 0, buf.length, offset) !== buf.length) {
                throw new Error('the repository archive changed while it was being uploaded');
              }
              const q = `session_id=${encodeURIComponent(sid)}&offset=${offset}`;
              const began = Date.now();
              r = await send('PUT', `${base}/chunk?${q}`, buf);
              if (r.retry) {
                if (attempt === BACKOFF_MS.length) throw gaveUp(r);
                await sleep(BACKOFF_MS[attempt++]);
                next = start; // the big slice may be what failed: retry small
                continue;
              }
              attempt = 0;
              if (r.status === 200) {
                const secs = (Date.now() - began) / 1000;
                const want = secs > 0
                  ? Math.floor((buf.length / secs) * TARGET_CHUNK_SECONDS / MIB) * MIB : max;
                next = Math.max(start, Math.min(max, want));
              } else if (r.status !== 409) {
                throw failure(r);
              }
            } else {
              r = await call('POST', `${base}/complete`, JSON.stringify({ session_id: sid, ...fields }));
              if (r.status >= 200 && r.status <= 299) return r.data;
              if (r.status !== 409) throw failure(r);
            }
            const got = r.data.received;
            if (!Number.isInteger(got) || got < 0 || got > size) throw failure(r);
            if (got > high) {
              high = got;
              stalls = 0;
            } else if (++stalls > MAX_STALLS) {
              throw new Error(`the upload stopped advancing at ${got} of ${size} bytes`);
            }
            offset = got;
          }
        } finally {
          fs.closeSync(fd);
        }
      }

      // Full-repository scan. Unlike the merge-request jobs this does not diff
      // anything: it ships the whole checkout to the Haxset engine, which runs the
      // complete SAST pipeline (24 vulnerability classes + dependencies, secrets and
      // infrastructure misconfigurations) against it. That takes far longer than a
      // pipeline should stay alive, so this job STARTS the scan and exits — the result
      // is emailed and lives in the platform.
      async function handleFullScan() {
        const zip = `${env.CI_PROJECT_DIR}/.haxset-repo.zip`;
        try {
          // `git archive` writes the tracked tree at HEAD, so `.git/` (and anything
          // marked export-ignore) never leaves the runner.
          execFileSync('git', ['archive', '--format=zip', '-o', zip, 'HEAD'], { stdio: 'pipe' });
        } catch (e) {
          await postNewNote('## Haxset Security Scanner\n\n⚠️ Could not package the repository '
            + `for a full scan.\n\n\`\`\`\n${scrub(e.message || e).slice(0, 300)}\n\`\`\``);
          bail('git archive failed: ' + scrub(e.message));
        }

        const size = fs.statSync(zip).size;
        log(`packaged ${(size / (1024 * 1024)).toFixed(1)} MB for a full repository scan`);

        // The fields go as JSON, so a branch name (git refnames permit `$`, backticks
        // and parentheses) or a free-text job variable is only ever a string.
        let out;
        try {
          out = await uploadArchive(REPO_SCAN_ENDPOINT, TOKEN, zip, {
            repo: env.CI_PROJECT_PATH || '',
            provider: 'gitlab',
            ref: env.CI_COMMIT_REF_NAME || '',
            commitSha: env.CI_COMMIT_SHA || '',
            mode: String(env.HAXSET_SCAN_MODE || 'thorough'),
            notify: String(env.HAXSET_NOTIFY || ''),
            full_scan: String(env.HAXSET_FULL_SCAN || ''),
            triggeredByLogin: env.GITLAB_USER_LOGIN || '',
          });
        } catch (e) {
          const detail = scrub(e.message || e).slice(0, 300);
          await postNewNote('## Haxset Security Scanner\n\n⚠️ The full repository scan could not '
            + `start.\n\n\`\`\`\n${detail}\n\`\`\``);
          bail('repo-scan start failed.');
        } finally {
          try { fs.unlinkSync(zip); } catch (e) { /* best-effort */ }
        }

        if (!out || !out.uuid) {
          await postNewNote('## Haxset Security Scanner\n\n⚠️ The full repository scan did not return '
            + `a scan id.\n\n\`\`\`\n${scrub(JSON.stringify(out)).slice(0, 300)}\n\`\`\``);
          bail('repo-scan returned no uuid.');
        }

        // The scan outlives this pipeline, so the note's job is to hand the reviewer a
        // LIVE link they can open right now — the scan page shows queue position and
        // per-phase progress while it runs, then becomes the report. Putting it on its
        // own line keeps it from reading as a footnote about some future artifact.
        const sha = String(env.CI_COMMIT_SHA || '').slice(0, 8);
        const track = out.url ? `**[▶ Track this scan in Haxset](${out.url})**\n\n` : '';
        await postNewNote('## Haxset Security Scanner\n\n'
          + `🔍 **Full repository scan started** — \`${env.CI_COMMIT_REF_NAME || 'HEAD'}\``
          + (sha ? ` @ \`${sha}\`` : '') + '\n\n'
          + track
          + 'That page shows live progress now and becomes the full report when the scan '
          + 'finishes. This scans the **entire repository**, not just the changes in this merge '
          + 'request, so it takes considerably longer than a merge-request scan — you do not need '
          + 'to keep this pipeline open. You will also get an email when the report is ready.');
        log(`full repository scan ${out.uuid} started — ${out.url || 'no url returned'}`);
      }

      (async () => {
        if (!TOKEN) {
          await postNote('## Haxset Security Scanner\n\n⚠️ The `HAXSET_SCANNER_TOKEN` CI/CD variable is '
            + 'not set. Add it under Settings > CI/CD > Variables.');
          bail('HAXSET_SCANNER_TOKEN missing.');
        }

        // A whole-repository scan is valid anywhere — it needs no merge request, so it
        // is dispatched BEFORE the merge-request guard below.
        if (COMMAND === 'fullscan') { await handleFullScan(); return; }

        if (!MR_IID) bail('Not a merge-request pipeline — nothing to scan.');

        const scanType = COMMAND === 'check' ? 'recheck' : (COMMAND === 'scan' ? 'full' : 'auto');

        let author = null;
        if (GL_TOKEN) {
          try {
            const r = await glFetch(`/projects/${PROJECT_ID}/merge_requests/${MR_IID}`);
            if (r.ok) author = (await r.json()).author || null;
            else log(`Could not read MR author (API ${r.status}); billing falls back to the pipeline user.`);
          } catch (e) { log('Could not read MR author: ' + e.message); }
        }
        const authorId = author ? author.id : (env.GITLAB_USER_ID || null);
        const authorLogin = author ? author.username : (env.GITLAB_USER_LOGIN || null);

        const BASE = env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
        const HEAD = env.CI_COMMIT_SHA;
        if (!BASE) bail('CI_MERGE_REQUEST_DIFF_BASE_SHA is empty — not a merge-request pipeline.');

        let shallow = false;
        try {
          shallow = execFileSync('git', ['rev-parse', '--is-shallow-repository']).toString().trim() === 'true';
        } catch (e) { /* older git — let the diff speak for itself */ }

        let diff = '';
        try {
          diff = execFileSync('git', ['diff', BASE, HEAD], { maxBuffer: 256 * 1024 * 1024 }).toString();
        } catch (e) {
          await postNote('## Haxset Security Scanner\n\n⚠️ Could not compute the merge-request diff'
            + (shallow
              ? ' because the repository was cloned shallowly, so the merge base is missing.\n\n'
                + 'This job sets `GIT_DEPTH: "0"`, but a **project or group** `GIT_DEPTH` variable '
                + 'overrides it. Remove it, or raise it enough to reach the merge base.'
              : '.\n\nEnsure this job keeps `GIT_DEPTH: "0"`.'));
          bail('git diff failed: ' + e.message);
        }
        if (!diff.trim()) {
          if (!isAuto) await postNote('## Haxset Security Scanner\n\nNo file changes between the base '
            + 'and head of this merge request, so there is nothing to scan.');
          bail('Empty diff.');
        }

        let names = [];
        try {
          names = execFileSync('git', ['-c', 'core.quotePath=false', 'diff', '--name-only', '--diff-filter=ACMR', BASE, HEAD],
            { maxBuffer: 64 * 1024 * 1024 }).toString().split('\n').filter(Boolean);
        } catch (e) {
          await postNote('## Haxset Security Scanner\n\n⚠️ Could not list the changed files of this '
            + 'merge request, so it was not scanned. Run **haxset-rescan** to try again.');
          bail('git diff --name-only failed: ' + e.message);
        }

        const FILE_LIMIT = 100 * 1024 * 1024;
        const TOTAL_LIMIT = 95 * 1024 * 1024;
        const files = {};
        const bypassed = [];
        const refused = [];
        let total = 0;
        // ⚠️ SYMLINKS ARE REJECTED, and that is a security control. statSync and
        // readFileSync both FOLLOW links, and this job holds HAXSET_SCANNER_TOKEN
        // and HAXSET_GITLAB_TOKEN in its environment while checking out a merge
        // request's tree. A contributor committing `notes.txt -> /proc/self/environ`
        // would otherwise have the runner's environment read into `files`,
        // uploaded, and potentially quoted back into a world-readable note through
        // a finding's description or a fix's original lines, which render verbatim.
        // lstat describes the LINK, so it is never followed; realpath containment
        // then also refuses a regular file reached through a symlinked parent.
        let projectRoot = '';
        try { projectRoot = fs.realpathSync(env.CI_PROJECT_DIR || process.cwd()); }
        catch (e) { projectRoot = ''; }
        for (const f of names) {
          try {
            const st = fs.lstatSync(f);
            if (st.isSymbolicLink() || !st.isFile()) { refused.push(f); continue; }
            if (projectRoot) {
              const resolved = fs.realpathSync(f);
              if (resolved !== projectRoot && !resolved.startsWith(projectRoot + pathMod.sep)) {
                refused.push(f);
                continue;
              }
            }
            if (st.size >= FILE_LIMIT || total + st.size > TOTAL_LIMIT) {
              bypassed.push(f);
              continue;
            }
            files[f] = fs.readFileSync(f, 'utf8');
            total += st.size;
          } catch (e) {
            // Reported, never silent: a file deleted between the diff and the read
            // was otherwise dropped under a note saying the changes were scanned.
            refused.push(f);
          }
        }
        if (refused.length) {
          log(`refused ${refused.length} path(s) that are not regular files inside the `
            + `checkout (symlinks are not followed): ${refused.join(', ')}`);
        }

        const payload = {
          provider: 'gitlab',
          repo: env.CI_MERGE_REQUEST_PROJECT_PATH || env.CI_PROJECT_PATH,
          prNumber: Number(MR_IID),
          prTitle: env.CI_MERGE_REQUEST_TITLE,
          commitSha: HEAD,
          branch: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME,
          prAuthorLogin: authorLogin,
          prAuthorId: authorId,
          triggeredByLogin: env.GITLAB_USER_LOGIN || null,
          scanType,
          diff, files,
        };

        const ATTEMPTS = Math.max(1, Number(env.HAXSET_START_RETRIES) || 4);
        const transient = (s) => s === 408 || s === 429 || s >= 500;
        let res = null, lastErr = '';
        for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
          try {
            res = await fetch(ENDPOINT, {
              method: 'POST',
              headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
              body: JSON.stringify(payload),
            });
          } catch (e) { res = null; lastErr = e.message; log(`start attempt ${attempt}/${ATTEMPTS}: ${e.message}`); }
          if (res && res.ok) break;
          if (res && !transient(res.status)) break;
          if (attempt < ATTEMPTS) {
            const backoff = Math.min(15000, 2000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 1000);
            if (res) log(`start attempt ${attempt}/${ATTEMPTS} got API ${res.status}; retrying in ${Math.round(backoff / 1000)}s`);
            await sleep(backoff);
          }
        }
        if (res === null) {
          await postNote(`## Haxset Security Scanner\n\n⚠️ Could not reach the Haxset API at \`${ENDPOINT}\` `
            + `after ${ATTEMPTS} attempt(s).\n\n\`\`\`\n${scrub(lastErr)}\n\`\`\``);
          bail('API unreachable.');
        }
        if (!res.ok) {
          const txt = scrub(await res.text()).slice(0, 300);
          await postNote(`## Haxset Security Scanner\n\n⚠️ The scan could not start — API \`${res.status}\``
            + `${apiHint(res.status)} (after ${ATTEMPTS} attempt(s)).\n\n\`\`\`\n${txt}\n\`\`\``);
          bail(`API ${res.status}: ${txt}`);
        }
        let data = await res.json();

        if (data && data.state === 'running' && data.scan_id) {
          const statusUrl = `${ENDPOINT}/${data.scan_id}`;
          const deadline = Date.now() + Number(env.HAXSET_POLL_MINUTES || 60) * 60 * 1000;
          log(`scan ${data.scan_id} running, polling`);
          while (Date.now() < deadline) {
            await sleep(10000);
            let pres;
            try { pres = await fetch(statusUrl, { headers: { Authorization: `Bearer ${TOKEN}` } }); }
            catch (e) { continue; }
            if (pres.status === 404) {
              await postNote('## Haxset Security Scanner\n\n⚠️ The scan result expired before it could be '
                + 'read. Run **haxset-rescan** to try again.');
              bail('Result expired.');
            }
            if (!pres.ok) continue;
            data = await pres.json();
            if (data.state && data.state !== 'running') break;
          }
          if (!data || data.state === 'running') {
            await postNote('## Haxset Security Scanner\n\n⚠️ The scan is taking longer than expected and did '
              + 'not finish within the polling window. It may still complete on the Haxset dashboard.');
            bail('Poll window elapsed.');
          }
        }

        if (data && (data.state === 'error' || data.ok === false)) {
          await postNote(`## Haxset Security Scanner\n\n⚠️ The scan could not complete — `
            + `${(data && data.message) || 'Haxset engine error.'}`);
          bail('Engine error.');
        }

        // The auto-scan gate: GitLab cannot express "only when the MR opens", so this
        // job re-fires on every push and the server skips it after the first. That skip
        // is NOT empty — it re-serves the last scan with the team's CURRENT triage
        // applied, which is how a decision taken in the Haxset platform reaches this
        // merge request without anyone spending a credit on a re-scan. Only bail when
        // there is genuinely nothing to show.
        const skipped = !!(data && data.skipped_reason === 'already_scanned');
        if (skipped) {
          const carries = (data.findings || []).length
            || (((data.secrets || {}).findings) || []).length
            || (((data.iac || {}).findings) || []).length
            || (((data.sca || {}).vulnerabilities) || []).length
            || data.suppressed_count;
          if (!carries) {
            if (!isAuto) await postNote(`## Haxset Security Scanner\n\n${data.message}`);
            bail('Already scanned — no credit used.');
          }
        }

        const allFindings = data.findings || [];
        // Four families, each with its own note section and label prefix:
        //   F#  AI code findings          -> data.findings
        //   S#  hardcoded secrets         -> data.secrets.findings
        //   I#  IaC/container misconfigs  -> data.iac.findings
        //   D#  vulnerable dependencies   -> data.sca.vulnerabilities
        // The last three are DETERMINISTIC SIDE ARTIFACTS: the backend returns each
        // under its own key and excludes all of them from the finding count, so a
        // "0 findings" summary can still carry a secrets section. All four are
        // triage-able by their label.
        //
        // The `|| allFindings.filter(...)` fallbacks keep this template working
        // against an OLDER backend that still shipped secrets/IaC inside `findings`
        // behind a finding_type discriminator, so template and backend can roll out
        // in either order.
        const artifactEntries = (a, key) => (a && Array.isArray(a[key]) ? a[key] : null);
        const secrets = artifactEntries(data.secrets, 'findings')
          || allFindings.filter((f) => f && f.finding_type === 'secret');
        const iac = artifactEntries(data.iac, 'findings')
          || allFindings.filter((f) => f && f.finding_type === 'iac');
        const findings = allFindings.filter((f) => !f || (f.finding_type !== 'secret' && f.finding_type !== 'iac'));
        const sca = (data.sca && data.sca.vulnerabilities) || [];
        const EMO = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '⚪' };

        // label -> fingerprint pairs for the hidden marker the triage job resolves
        // against. Declared out here because the SCA section renders AFTER the
        // if/else chain and contributes D# labels of its own.
        const markerLines = [];
        let body = '## Haxset Security Scanner\n\n';
        // Say plainly that this is the stored result being re-rendered, not a new scan
        // of the current commit — otherwise the counts look like they describe code
        // that may since have changed.
        if (skipped && data.message) body += `_${data.message}_\n\n`;
        if (data.recheck) {
          const sum = data.recheck_summary || { total: allFindings.length, fixed: 0, not_fixed: allFindings.length, triaged: 0 };
          if (allFindings.length === 0) {
            body += (data.message || 'Re-check complete — no prior findings to verify.') + '\n';
          } else {
            const triagedTotal = sum.triaged || 0;
            body += `<details open>\n<summary><b>Re-check: ${sum.fixed}/${sum.total} fixed`
              + `${triagedTotal ? ` · ${triagedTotal} triaged` : ''}</b></summary>\n\n`;
            for (const f of allFindings) {
              const loc = `${f.file_path}${f.line_number ? ':' + f.line_number : ''}`;
              const sev = (f.severity || '').toUpperCase();
              const triaged = f.status === 'triaged' || f.triage === 'false_positive' || f.triage === 'accepted_risk';
              const ok = f.status === 'fixed';
              let icon, label;
              if (triaged) { icon = '✅'; label = f.triage === 'accepted_risk' ? 'ACCEPTED RISK' : 'FALSE POSITIVE'; }
              else if (ok) { icon = '✅'; label = 'FIXED'; }
              else { icon = '⚠️'; label = 'STILL PRESENT'; }
              body += `<details>\n<summary>${icon} <b>${label}</b> — ${EMO[f.severity] || '⚪'} ${sev} ${safeText(f.title, 300)} (<code>${safeText(loc, 300)}</code>)</summary>\n\n`;
              if (f.recheck_note) body += `${safeText(f.recheck_note)}\n\n`;
              if (!ok && !triaged && f.remediation) body += `**Fix:** ${safeText(f.remediation)}\n\n`;
              body += `</details>\n\n`;
            }
            body += `</details>\n\n`;
          }
        } else if (!data.relevant) {
          body += '✅ No security-relevant code changes detected in this merge request.\n';
        } else if (findings.length === 0 && secrets.length === 0 && iac.length === 0 && sca.length === 0) {
          // ⚠️ Every family must be counted here. Secrets and IaC no longer live in
          // `findings`, so testing only that would report a merge request whose sole
          // problem is a leaked credential as "no issues found".
          body += data.suppressed_count
            ? `✅ No new issues. ${data.suppressed_count} previously-triaged finding(s) are hidden (false positive / accepted risk).\n`
            : '✅ Scanned the changed code — no issues found.\n';
        } else {
          if (secrets.length) {
            body += `<details open>\n<summary><b>🔑 ${secrets.length} hardcoded secret${secrets.length === 1 ? '' : 's'}</b></summary>\n\n`;
            secrets.forEach((f, idx) => {
              const id = 'S' + (idx + 1);
              const loc = `${f.file_path}${f.line_number ? ':' + f.line_number : ''}`;
              const sev = (f.severity || '').toUpperCase();
              const confirmed = f.triage === 'true_finding' ? ' · ✅ confirmed' : '';
              body += `<details>\n<summary>${EMO[f.severity] || '⚪'} <b>${sev}</b> <code>${id}</code> — ${safeText(f.title, 300)} (<code>${safeText(loc, 300)}</code>)${confirmed}</summary>\n\n`;
              if (f.code_snippet) { const sf = fenceFor(String(f.code_snippet).split('\n')); body += sf + '\n' + String(f.code_snippet).slice(0, 2000) + '\n' + sf + '\n\n'; }
              if (f.description) body += `${safeText(f.description)}\n\n`;
              if (f.remediation) body += `**Fix:** ${safeText(f.remediation)}\n\n`;
              body += `</details>\n\n`;
              if (f.fingerprint) markerLines.push(`${id} ${f.fingerprint}`);
            });
            body += `</details>\n\n`;
          }
          if (findings.length) {
            body += `<details open>\n<summary><b>${findings.length} code finding${findings.length === 1 ? '' : 's'}</b></summary>\n\n`;
            findings.forEach((f, idx) => {
              const id = 'F' + (idx + 1);
              const loc = `${f.file_path}${f.line_number ? ':' + f.line_number : ''}`;
              const sev = (f.severity || '').toUpperCase();
              const confirmed = f.triage === 'true_finding' ? ' · ✅ confirmed' : '';
              body += `<details>\n<summary>${EMO[f.severity] || '⚪'} <b>${sev}</b> <code>${id}</code> — ${safeText(f.title, 300)} (<code>${safeText(loc, 300)}</code>)${confirmed}</summary>\n\n`;
              if (f.cwe_id) body += `**${safeText(f.cwe_id, 100)}**\n\n`;
              if (f.description) body += `${safeText(f.description)}\n\n`;
              if (f.remediation) body += `**Fix:** ${safeText(f.remediation)}\n\n`;
              body += `</details>\n\n`;
              if (f.fingerprint) markerLines.push(`${id} ${f.fingerprint}`);
            });
            body += `</details>\n\n`;
          }
          if (iac.length) {
            // Deterministic IaC / container misconfigurations (Trivy embedded checks
            // + the Haxset custom pack). I# labels; a 🛡️ badge marks a Haxset-
            // exclusive check that bare open-source scanners don't ship.
            const custom = iac.filter((f) => f && f.is_custom).length;
            body += `<details open>\n<summary><b>⚙️ ${iac.length} infrastructure misconfiguration${iac.length === 1 ? '' : 's'}${custom ? ` · ${custom} Haxset custom` : ''}</b></summary>\n\n`;
            iac.forEach((f, idx) => {
              const id = 'I' + (idx + 1);
              const loc = `${f.file_path}${f.line_number ? ':' + f.line_number : ''}`;
              const sev = (f.severity || '').toUpperCase();
              const confirmed = f.triage === 'true_finding' ? ' · ✅ confirmed' : '';
              const badge = f.is_custom ? ' · 🛡️ Haxset' : '';
              body += `<details>\n<summary>${EMO[f.severity] || '⚪'} <b>${sev}</b> <code>${id}</code> — ${safeText(f.title, 300)} (<code>${safeText(loc, 300)}</code>)${badge}${confirmed}</summary>\n\n`;
              const meta = [f.rule_id, f.category, f.resource].filter(Boolean).map((m) => safeText(m, 200));
              if (meta.length) body += meta.map((m) => `\`${m}\``).join(' · ') + '\n\n';
              if (f.description) body += `${safeText(f.description)}\n\n`;
              if (f.remediation) body += `**Fix:** ${safeText(f.remediation)}\n\n`;
              body += `</details>\n\n`;
              if (f.fingerprint) markerLines.push(`${id} ${f.fingerprint}`);
            });
            body += `</details>\n\n`;
          }
          if (data.suppressed_count) {
            body += `<sub>${data.suppressed_count} finding${data.suppressed_count === 1 ? '' : 's'} hidden by triage.</sub>\n\n`;
          }
        }
        if (sca.length) {
          // Dependencies are triage-able too (D#), so this section renders like the
          // other three: clickable header, one collapsible row per entry, and a
          // fingerprint pushed to the hidden marker.
          const advisories = sca.reduce((n, v) => n + (v.vuln_count || 1), 0);
          body += `<details open>\n<summary><b>📦 ${sca.length} vulnerable dependenc${sca.length === 1 ? 'y' : 'ies'} (${advisories} advisories)</b></summary>\n\n`;
          sca.forEach((v, idx) => {
            const id = 'D' + (idx + 1);
            const cnt = v.vuln_count || (v.cves ? v.cves.length : 1);
            const sev = (v.severity || '').toUpperCase();
            const upgrade = v.fixed_version && v.fixed_version !== '-' ? `, upgrade to \`${safeText(v.fixed_version, 100)}\`` : '';
            const cves = (v.cves && v.cves.length ? v.cves : (v.cve && v.cve !== '-' ? [v.cve] : []));
            const confirmed = v.triage === 'true_finding' ? ' · ✅ confirmed' : '';
            body += `<details>\n<summary>${EMO[v.severity] || '⚪'} <b>${sev}</b> <code>${id}</code> — <code>${safeText(v.package, 200)}@${safeText(v.current_version, 100)}</code> (${cnt} known vuln${cnt === 1 ? '' : 's'})${confirmed}</summary>\n\n`;
            body += `${cnt} known vulnerabilit${cnt === 1 ? 'y' : 'ies'}${upgrade}.\n\n`;
            if (cves.length) body += `${safeText(cves.join(', '), 2000)}\n\n`;
            body += `</details>\n\n`;
            if (v.fingerprint) markerLines.push(`${id} ${v.fingerprint}`);
          });
          body += `</details>\n\n`;
        }
        // ── One-click fixes ───────────────────────────────────────────────────
        // Planned BEFORE the note is posted, because the fallback has to be folded
        // into that same note — posting twice would leave a reviewer reading two
        // half-answers. A re-check verifies prior findings rather than producing
        // new patches, so it never carries suggestions.
        const fixEntries = (data.recheck || !SUGGESTIONS_ON) ? [] : collectFixes([
          { prefix: 'F', items: findings },
          { prefix: 'I', items: iac },
        ]);
        let diffRefs = null;
        if (fixEntries.length) {
          diffRefs = await mrDiffRefs();
          if (!diffRefs) {
            // No api-scoped token, or the refs could not be read: the reviewer
            // still gets every patch, just not as a button.
            body += `<details open>\n<summary><b>🛠️ ${fixEntries.length} suggested fix`
              + `${fixEntries.length === 1 ? '' : 'es'}</b></summary>\n\n`
              + 'These could not be posted as one-click suggestions (an api-scoped '
              + '`HAXSET_GITLAB_TOKEN` is required for that). Apply them by hand:\n\n'
              + fixEntries.map(fixFencedDiff).join('')
              + '</details>\n\n';
          }
        }

        // The hidden marker comes after EVERY section so it covers all four label
        // families in one block.
        if (markerLines.length) body += `<!-- haxset-findings\n${markerLines.join('\n')}\n-->\n\n`;
        const notScanned = [].concat(bypassed || [], refused || [], data.unscanned_files || [], data.truncated_files || []);
        if (notScanned.length) {
          body += `<details>\n<summary>⚠️ ${notScanned.length} file(s) not fully scanned</summary>\n\n`;
          if ((bypassed || []).length) body += `Bypassed (>= 100 MB): ${pathList(bypassed)}\n\n`;
          if ((refused || []).length) body += `Not uploaded (not a regular file inside the checkout - symlinks are never followed): ${pathList(refused)}\n\n`;
          if ((data.unscanned_files || []).length) body += `Not scanned (exceeded scan budget): ${pathList(data.unscanned_files)}\n\n`;
          if ((data.truncated_files || []).length) body += `Partially scanned (truncated): ${pathList(data.truncated_files)}\n\n`;
          body += `Contact support@haxset.com for a full review of large diffs.\n\n</details>\n\n`;
        }

        await postNote(body);

        // Posted AFTER the summary so the diff notes read as annotations on an
        // explanation the reviewer already has, not as context-free patches.
        if (fixEntries.length && diffRefs) {
          const res = await postSuggestions(fixEntries, diffRefs);
          if (res.posted) {
            log(`Posted ${res.posted} one-click fix suggestion(s) on this merge request.`);
          } else {
            // SALVAGE. Every discussion was rejected (a moved line, a stale
            // position, a permission change) and the summary note already went out
            // without a fallback — so without this the patches were computed,
            // verified and then discarded in silence. Mirrors the GitHub action's
            // salvage path.
            log(`none of the ${fixEntries.length} fix suggestion(s) could be posted as `
              + 'diff notes; adding them to the merge request as copy-pasteable diffs.');
            await postNewNote('## Haxset Security Scanner\n\n'
              + `<details open>\n<summary><b>🛠️ ${fixEntries.length} suggested fix`
              + `${fixEntries.length === 1 ? '' : 'es'}</b></summary>\n\n`
              + 'These could not be posted as one-click suggestions on the diff. '
              + 'Apply them by hand:\n\n'
              + fixEntries.map(fixFencedDiff).join('')
              + '</details>\n\n');
          }
        }

        log(`${findings.length} findings, ${secrets.length} secrets, ${sca.length} vulnerable deps, `
          + `${fixEntries.length} fix(es).`);
      })().catch(async (e) => {
        console.warn('[haxset] unexpected error: ' + (e && e.stack || e));
        process.exit(0);
      });
      HAXSET_EOF

haxset-scan:
  extends: .haxset_base
  variables:
    HAXSET_COMMAND: "auto"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: always
      allow_failure: true

haxset-rescan:
  extends: .haxset_base
  variables:
    HAXSET_COMMAND: "scan"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
      allow_failure: true

haxset-check:
  extends: .haxset_base
  variables:
    HAXSET_COMMAND: "check"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
      allow_failure: true

# allow_failure: true on EVERY rule — inside `rules:` the default flips to false, and a
# never-run manual job counts as pending, which blocks merges where pipelines are required.
haxset-sast:
  extends: .haxset_base
  variables:
    HAXSET_COMMAND: "fullscan"
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
      allow_failure: true
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
      allow_failure: true
