/*
 * Copyright 2026, Jaime Terrats.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import { execFileSync } from 'node:child_process';
import { isBinaryAvailable } from './dependency-check.js';
// ─── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 2000;
const DEFAULT_MAX_DIFF_BYTES = 5 * 1024 * 1024; // 5MB
const IGNORE_COMMENT = '// secret-scan:ignore';
// ─── Glob matching ────────────────────────────────────────────────────────────
export function globMatch(filepath, pattern) {
    const parts = pattern.split('**');
    const regexStr = parts
        .map((part) => part
        .replace(/[.+^${}()|[\]\\]/g, '\\$&')
        .replace(/\*/g, '[^/]*')
        .replace(/\?/g, '[^/]'))
        .join('.*');
    return new RegExp(`^${regexStr}$`).test(filepath);
}
const SECRET_PATTERNS = [
    { name: 'AWS Access Key', regex: /(?:^|[^A-Z0-9])(?:AKIA[0-9A-Z]{16})(?:[^A-Z0-9]|$)/gm },
    { name: 'AWS Secret Key', regex: /(?:aws_secret_access_key|secret_access_key)\s*[=:]\s*["']?([A-Za-z0-9/+=]{40})["']?/gim },
    { name: 'GitHub PAT (classic)', regex: /ghp_[A-Za-z0-9]{36,}/g },
    { name: 'GitHub PAT (fine-grained)', regex: /github_pat_[A-Za-z0-9_]{22,}/g },
    { name: 'GitHub OAuth Token', regex: /gho_[A-Za-z0-9]{36,}/g },
    { name: 'Private Key Header', regex: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
    { name: 'High-Entropy Token', regex: /(?:token|secret|password|api_key|apikey)\s*[=:]\s*["']([A-Za-z0-9+/=_-]{32,})["']/gim },
];
// ─── Gitleaks Strategy ────────────────────────────────────────────────────────
export class GitleaksStrategy {
    name = 'gitleaks';
    // eslint-disable-next-line class-methods-use-this
    available() {
        return isBinaryAvailable('gitleaks').available;
    }
    // eslint-disable-next-line class-methods-use-this, @typescript-eslint/require-await
    async scan(diff, options) {
        const cwd = options.cwd ?? process.cwd();
        const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
        try {
            const stdout = execFileSync('gitleaks', ['detect', '--no-git', '--pipe', '--report-format', 'json'], {
                input: diff,
                encoding: 'utf8',
                cwd,
                timeout,
                stdio: ['pipe', 'pipe', 'pipe'],
            });
            return this.parseOutput(stdout);
        }
        catch (err) {
            const e = err;
            if (e.status === 1 && e.stdout) {
                return this.parseOutput(e.stdout);
            }
            return [];
        }
    }
    parseOutput(stdout) {
        if (!stdout.trim())
            return [];
        try {
            const entries = JSON.parse(stdout);
            return entries.map((e) => ({
                file: e.File ?? 'unknown',
                line: e.StartLine ?? 0,
                match: this.redact(e.Match ?? ''),
                pattern: e.RuleID ?? 'gitleaks',
                strategy: 'gitleaks',
            }));
        }
        catch {
            return [];
        }
    }
    // eslint-disable-next-line class-methods-use-this
    redact(match) {
        if (match.length <= 8)
            return '***';
        return match.slice(0, 4) + '***' + match.slice(-4);
    }
}
// ─── Regex Strategy ───────────────────────────────────────────────────────────
export class RegexStrategy {
    name = 'regex';
    // eslint-disable-next-line class-methods-use-this
    available() {
        return true;
    }
    // eslint-disable-next-line class-methods-use-this, @typescript-eslint/require-await
    async scan(diff, options) {
        const findings = [];
        const lines = diff.split('\n');
        let currentFile = 'unknown';
        let lineNumber = 0;
        for (const line of lines) {
            // Track file from diff headers
            const fileMatch = /^\+\+\+ b\/(.+)$/.exec(line);
            if (fileMatch) {
                currentFile = fileMatch[1];
                lineNumber = 0;
                continue;
            }
            // Track line numbers from hunk headers
            const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
            if (hunkMatch) {
                lineNumber = parseInt(hunkMatch[1], 10) - 1;
                continue;
            }
            // Only scan added lines
            if (!line.startsWith('+'))
                continue;
            lineNumber++;
            // Skip lines with ignore comment
            if (line.includes(IGNORE_COMMENT))
                continue;
            // Skip if file matches allowlist
            if (options.allowlist?.some((glob) => globMatch(currentFile, glob)))
                continue;
            const content = line.slice(1); // Remove leading '+'
            for (const pattern of SECRET_PATTERNS) {
                pattern.regex.lastIndex = 0;
                let m = pattern.regex.exec(content);
                while (m) {
                    findings.push({
                        file: currentFile,
                        line: lineNumber,
                        match: this.redact(m[0]),
                        pattern: pattern.name,
                        strategy: 'regex',
                    });
                    m = pattern.regex.exec(content);
                }
            }
        }
        return findings;
    }
    // eslint-disable-next-line class-methods-use-this
    redact(match) {
        if (match.length <= 8)
            return '***';
        return match.slice(0, 4) + '***' + match.slice(-4);
    }
}
// ─── Staged diff retrieval ────────────────────────────────────────────────────
export function getStagedDiff(cwd, timeoutMs) {
    try {
        return execFileSync('git', ['diff', '--cached', '--unified=0'], {
            cwd,
            encoding: 'utf8',
            timeout: timeoutMs,
            stdio: ['pipe', 'pipe', 'pipe'],
        });
    }
    catch {
        return '';
    }
}
// ─── Main export ──────────────────────────────────────────────────────────────
export async function scanStagedFiles(options = {}) {
    const start = Date.now();
    const cwd = options.cwd ?? process.cwd();
    const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    const maxDiffBytes = options.maxDiffBytes ?? DEFAULT_MAX_DIFF_BYTES;
    // Get staged diff
    const diff = getStagedDiff(cwd, timeoutMs);
    // Early abort: no staged changes
    if (!diff) {
        return { exitCode: 0, findings: [], strategy: 'regex', durationMs: Date.now() - start };
    }
    // Early abort: diff too large
    if (Buffer.byteLength(diff) > maxDiffBytes) {
        return {
            exitCode: 2,
            findings: [],
            strategy: 'regex',
            durationMs: Date.now() - start,
            error: `Diff size exceeds ${maxDiffBytes} bytes limit; skipping scan`,
        };
    }
    // Strategy selection: prefer gitleaks if available
    const gitleaks = new GitleaksStrategy();
    const regex = new RegexStrategy();
    const strategy = gitleaks.available() ? gitleaks : regex;
    try {
        const findings = await strategy.scan(diff, options);
        const exitCode = findings.length > 0 ? 1 : 0;
        return { exitCode, findings, strategy: strategy.name, durationMs: Date.now() - start };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { exitCode: 2, findings: [], strategy: strategy.name, durationMs: Date.now() - start, error: msg };
    }
}
//# sourceMappingURL=secret-scan.js.map