package com.example.support;

import io.restassured.RestAssured;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A lightweight, owned API security scanner (L3git). Fires a battery of probes at ONE endpoint and
 * reports evidence-based findings, tagged to the OWASP API Security Top 10. Families:
 *   - injection: SQL, NoSQL, XSS (reflected), path traversal, template injection (SSTI), CRLF
 *     header injection - placed into the endpoint's REAL query params and JSON body fields, not
 *     just a synthetic param;
 *   - robustness / fuzzing: oversized value, malformed JSON body;
 *   - auth / access control: missing-auth, unvalidated token, JWT alg=none + stripped signature;
 *   - configuration: security headers, CORS reflection, plain-HTTP transport, advertised write
 *     methods (OPTIONS Allow), TRACE (XST), version + stack-trace info leakage.
 *
 * Report-only by design - it never fails the build; it prints what it found so a human decides.
 * This is a shift-left SMOKE scanner, not a replacement for a dedicated DAST/pentest. Only scan
 * APIs you are authorized to test (your own). No destructive HTTP methods are ever sent - write
 * methods are only REPORTED from what the server advertises. Generated / shipped by L3git.
 */
public class SecurityScan {

    // Injection payloads placed in query params. {label, value, category, kind, extra}
    // kind: reflect | sqlerr | nosqlerr | filesig | ssti | crlf   (extra = product / header name)
    private static final String[][] QUERY_PAYLOADS = {
            {"SQL injection", "' OR '1'='1", "injection/sql", "sqlerr", ""},
            {"SQL injection", "1' OR '1'='1' -- ", "injection/sql", "sqlerr", ""},
            {"XSS", "<script>alert(1)</script>", "injection/xss", "reflect", ""},
            {"Path traversal", "../../../../etc/passwd", "injection/path-traversal", "filesig", ""},
            {"Path traversal", "..\\..\\..\\..\\windows\\win.ini", "injection/path-traversal", "filesig", ""},
            {"Template injection (SSTI)", "${1337*1337}", "injection/ssti", "ssti", "1787569"},
            {"Template injection (SSTI)", "{{1337*1337}}", "injection/ssti", "ssti", "1787569"},
            {"CRLF / header injection", "probe\r\nX-L3git-Inj: injected", "injection/crlf", "crlf", "X-L3git-Inj"},
    };
    // Subset that also makes sense inside a JSON string body field.
    private static final String[][] BODY_PAYLOADS = {
            {"SQL injection", "' OR '1'='1", "injection/sql", "sqlerr", ""},
            {"XSS", "<script>alert(1)</script>", "injection/xss", "reflect", ""},
            {"Template injection (SSTI)", "${1337*1337}", "injection/ssti", "ssti", "1787569"},
    };

    private static final String[] SQL_ERRORS = {
            "sql syntax", "sqlstate", "ora-0", "mysql_fetch", "postgresql", "sqlite",
            "unclosed quotation", "quoted string not properly terminated", "odbc", "syntax error at or near",
    };
    private static final String[] NOSQL_ERRORS = {
            "mongo", "bson", "$where", "e11000", "cast to objectid failed", "unknown operator",
    };
    private static final String[] STACK_SIGNS = {
            "exception", "at java.", "at com.", "traceback (most recent call last)",
            "system.web", ".java:", "stack trace", "nullpointerexception", "whitelabel error page",
    };

    private static final int MAX_QUERY_TARGETS = 5;
    private static final int MAX_BODY_TARGETS = 5;
    private static final String CORS_PROBE_ORIGIN = "https://l3git-cors-probe.invalid";

    public static List<SecurityFinding> scan(ApiContext api, String method, String path, String body) {
        List<SecurityFinding> out = new ArrayList<>();
        String m = method == null ? "GET" : method.toUpperCase();

        // --- transport: plain HTTP means everything below travels in the clear ---
        String effective = (api.baseUri != null && !api.baseUri.isEmpty() && !path.toLowerCase().startsWith("http"))
                ? api.baseUri : path;
        if (effective != null && effective.toLowerCase().startsWith("http://")) {
            out.add(new SecurityFinding("medium", "transport", "API base URL uses plain HTTP (no TLS) - traffic and tokens are exposed"));
        }

        // --- baseline: headers + info leakage + CORS ---
        Response base = send(api, m, path, body, false, null, null);
        if (base != null) {
            checkHeaders(base, out);
            checkLeakage(base, "baseline response", out);
        }
        checkCors(api, m, path, body, out);

        // --- injection into REAL query params + JSON body fields (synthetic param as fallback) ---
        runInjection(api, m, path, body, out);

        // --- fuzzing: oversized value + malformed JSON body ---
        Response big = send(api, m, appendParam(path, "secscan", repeat("A", 100000)), body, false, null, null);
        if (big != null && big.getStatusCode() >= 500) {
            out.add(new SecurityFinding("medium", "robustness", "oversized input caused a " + big.getStatusCode() + " (missing length limits)"));
        }
        Response malformed = send(api, m, path, "{\"broken\": ", false, null, null);
        if (malformed != null) {
            checkLeakage(malformed, "malformed JSON body", out);
        }

        // --- method surface: OPTIONS advertises which verbs exist; TRACE = cross-site tracing.
        //     NEVER send PUT/DELETE/PATCH as a probe (would mutate data) - only report them. ---
        checkMethods(api, m, path, out);

        // --- auth / access control: strip auth, then send a bogus token ---
        boolean hadAuth = false;
        for (String k : api.headers.keySet()) {
            if (k.equalsIgnoreCase("Authorization")) {
                hadAuth = true;
                break;
            }
        }
        if (hadAuth) {
            Response noAuth = send(api, m, path, body, true, null, null);
            if (noAuth != null && is2xx(noAuth)) {
                out.add(new SecurityFinding("high", "auth/access-control", "endpoint returned " + noAuth.getStatusCode() + " with NO Authorization header - verify it should be public"));
            }
            Response badTok = send(api, m, path, body, true, "Bearer invalid.token.value", null);
            if (badTok != null && is2xx(badTok)) {
                out.add(new SecurityFinding("high", "auth/access-control", "endpoint returned " + badTok.getStatusCode() + " with an INVALID token - token may not be validated"));
            }
        }

        // --- JWT tampering: if the request carries a Bearer JWT, try alg=none and a stripped
        //     signature. A correctly-verifying API rejects both (401); a 2xx means it does not. ---
        String bearer = null;
        for (Map.Entry<String, String> e : api.headers.entrySet()) {
            if (e.getKey().equalsIgnoreCase("Authorization") && e.getValue() != null
                    && e.getValue().regionMatches(true, 0, "Bearer ", 0, 7)) {
                bearer = e.getValue().substring(7).trim();
                break;
            }
        }
        if (bearer != null) {
            String[] jp = bearer.split("\\.");
            if (jp.length == 3) {
                String noneHeader = Base64.getUrlEncoder().withoutPadding()
                        .encodeToString("{\"alg\":\"none\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
                Response none = send(api, m, path, body, true, "Bearer " + noneHeader + "." + jp[1] + ".", null);
                if (none != null && is2xx(none)) {
                    out.add(new SecurityFinding("high", "auth/jwt", "JWT with alg=none accepted (" + none.getStatusCode() + ") - signature not verified"));
                }
                Response stripped = send(api, m, path, body, true, "Bearer " + jp[0] + "." + jp[1] + ".", null);
                if (stripped != null && is2xx(stripped)) {
                    out.add(new SecurityFinding("high", "auth/jwt", "JWT with a stripped signature accepted (" + stripped.getStatusCode() + ")"));
                }
                // Claim tampering: escalate role/admin in the payload, KEEP the original signature.
                // A verifying API rejects it (the signature no longer matches); a 2xx = claims trusted.
                try {
                    String payloadJson = new String(Base64.getUrlDecoder().decode(padB64(jp[1])), StandardCharsets.UTF_8);
                    String tampered = injectFields(payloadJson, "\"role\":\"admin\",\"isAdmin\":true,\"admin\":true");
                    String encPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(tampered.getBytes(StandardCharsets.UTF_8));
                    Response claim = send(api, m, path, body, true, "Bearer " + jp[0] + "." + encPayload + "." + jp[2], null);
                    if (claim != null && is2xx(claim)) {
                        out.add(new SecurityFinding("high", "auth/jwt", "JWT payload claims tampered (role/admin escalated) and still accepted (" + claim.getStatusCode() + ") - claims are trusted without a valid signature"));
                    }
                } catch (Exception ignore) {
                    // malformed base64 payload - skip
                }
            }
        }

        // --- Tier-2 object/property-level probes ---
        checkBola(api, m, path, body, base, out);
        checkMassAssignment(api, m, path, body, out);
        checkGraphQLIntrospection(api, m, path, out);
        return out;
    }

    // ----- BOLA / IDOR: change a numeric object id in the path; if a NEIGHBORING id also returns
    //       data, the endpoint may not be checking that the object belongs to the caller. This
    //       cannot be auto-confirmed (the other object might legitimately be yours), so it is a
    //       report-only "verify manually" finding. -----
    private static void checkBola(ApiContext api, String m, String path, String body, Response base, List<SecurityFinding> out) {
        if (base == null || !is2xx(base)) {
            return;   // only meaningful when the original id returns an object
        }
        String p = path;
        String query = "";
        int qi = p.indexOf('?');
        if (qi >= 0) {
            query = p.substring(qi);
            p = p.substring(0, qi);
        }
        String[] segs = p.split("/");
        int idIdx = -1;
        long idVal = -1;
        for (int i = segs.length - 1; i >= 0; i--) {
            if (segs[i].matches("\\d{1,15}")) {
                idIdx = i;
                idVal = Long.parseLong(segs[i]);
                break;
            }
        }
        if (idIdx < 0) {
            return;
        }
        String baseBody = safeBody(base);
        for (long alt : new long[]{idVal + 1, idVal - 1}) {
            if (alt < 0) {
                continue;
            }
            segs[idIdx] = Long.toString(alt);
            Response r = send(api, m, String.join("/", segs) + query, body, false, null, null);
            segs[idIdx] = Long.toString(idVal);
            if (r != null && is2xx(r)) {
                String rb = safeBody(r);
                if (!rb.isEmpty() && !rb.equals(baseBody)) {
                    out.add(new SecurityFinding("medium", "bola",
                            "changing the object id in the path (" + idVal + " -> " + alt + ") returned different data (" + r.getStatusCode() + ") - VERIFY this object belongs to the caller (possible BOLA/IDOR)"));
                    break;
                }
            }
        }
    }

    // ----- Mass assignment: inject privileged fields into the JSON body; flag if the server accepts
    //       (and especially if it REFLECTS) them instead of ignoring/rejecting unknown fields. -----
    private static void checkMassAssignment(ApiContext api, String m, String path, String body, List<SecurityFinding> out) {
        if (body == null || !body.trim().startsWith("{")) {
            return;
        }
        String injected = injectFields(body, "\"role\":\"admin\",\"isAdmin\":true,\"is_admin\":true,\"admin\":true");
        Response r = send(api, m, path, injected, false, null, null);
        if (r == null || !is2xx(r)) {
            return;
        }
        String rb = safeBody(r);
        boolean reflected = rb.contains("\"isAdmin\":true") || rb.contains("\"admin\":true")
                || rb.matches("(?s).*\"role\"\\s*:\\s*\"admin\".*");
        if (reflected) {
            out.add(new SecurityFinding("medium", "mass-assignment", "server accepted AND reflected an injected privileged field (role/isAdmin) - possible mass assignment"));
        } else {
            out.add(new SecurityFinding("low", "mass-assignment", "server accepted extra privileged fields without rejecting them (" + r.getStatusCode() + ") - verify they are ignored, not bound"));
        }
    }

    // ----- GraphQL introspection: if this looks like a GraphQL endpoint, ask for the schema. -----
    private static void checkGraphQLIntrospection(ApiContext api, String m, String path, List<SecurityFinding> out) {
        if (path == null || !path.toLowerCase().contains("graphql")) {
            return;
        }
        Response r = send(api, "POST", path, "{\"query\":\"query{__schema{types{name}}}\"}", false, null, null);
        if (r == null || !is2xx(r)) {
            return;
        }
        String rb = safeBody(r);
        if (rb.contains("__schema") || (rb.contains("\"types\"") && rb.contains("\"name\""))) {
            out.add(new SecurityFinding("low", "graphql/introspection", "GraphQL introspection is enabled - the full schema is exposed; disable it in production if unintended"));
        }
    }

    // ----- injection: build targets (real query params + JSON body fields), fire each payload -----
    private static void runInjection(ApiContext api, String m, String path, String body, List<SecurityFinding> out) {
        // Query targets: each existing param, plus a synthetic 'secscan' fallback.
        List<String> paramNames = queryParamNames(path);
        int qUsed = 0;
        for (String name : paramNames) {
            if (qUsed >= MAX_QUERY_TARGETS) {
                break;
            }
            for (String[] p : QUERY_PAYLOADS) {
                Response r = send(api, m, setQueryParam(path, name, p[1]), body, false, null, null);
                evalInjection(r, p, "query param '" + name + "'", out);
            }
            qUsed++;
        }
        for (String[] p : QUERY_PAYLOADS) {   // synthetic fallback - catches endpoints with no params
            Response r = send(api, m, appendParam(path, "secscan", p[1]), body, false, null, null);
            evalInjection(r, p, "query param 'secscan'", out);
        }

        // Body targets: each JSON string value gets each body payload; plus a NoSQL operator swap.
        if (body != null && body.trim().startsWith("{")) {
            int used = 0;
            Matcher fm = STRING_FIELD.matcher(body);
            while (fm.find() && used < MAX_BODY_TARGETS) {
                String key = fm.group(1);
                int vStart = fm.start(2), vEnd = fm.end(2);
                for (String[] p : BODY_PAYLOADS) {
                    String variant = body.substring(0, vStart) + jsonEscape(p[1]) + body.substring(vEnd);
                    Response r = send(api, m, path, variant, false, null, null);
                    evalInjection(r, p, "body field '" + key + "'", out);
                }
                // NoSQL: replace the quoted string value with a query operator object.
                String nvar = body.substring(0, vStart - 1) + "{\"$gt\":\"\"}" + body.substring(vEnd + 1);
                Response nr = send(api, m, path, nvar, false, null, null);
                if (nr != null) {
                    String low = safeBody(nr).toLowerCase();
                    if (containsAny(low, NOSQL_ERRORS)) {
                        out.add(new SecurityFinding("high", "injection/nosql", "NoSQL operator injected into body field '" + key + "' surfaced a database error"));
                    } else if (nr.getStatusCode() >= 500) {
                        out.add(new SecurityFinding("medium", "robustness", "NoSQL operator in body field '" + key + "' caused a " + nr.getStatusCode()));
                    }
                }
                used++;
            }
        }
    }

    private static void evalInjection(Response r, String[] p, String where, List<SecurityFinding> out) {
        if (r == null) {
            return;
        }
        String bodyText = safeBody(r);
        String low = bodyText.toLowerCase();
        String value = p[1], category = p[2], kind = p[3], extra = p[4];
        switch (kind) {
            case "reflect":
                if (bodyText.contains(value)) {
                    out.add(new SecurityFinding("medium", category, "payload reflected unescaped (possible XSS) in " + where + ": " + value));
                }
                break;
            case "sqlerr":
                if (containsAny(low, SQL_ERRORS)) {
                    out.add(new SecurityFinding("high", category, "database error surfaced by payload (possible SQL injection) in " + where));
                }
                break;
            case "filesig":
                if (low.matches("(?s).*root:.*:0:0:.*") || low.contains("[extensions]") || low.contains("for 16-bit app support")) {
                    out.add(new SecurityFinding("high", category, "system file contents returned (path traversal) in " + where));
                }
                break;
            case "ssti":
                if (bodyText.contains(extra) && !bodyText.contains(value)) {
                    out.add(new SecurityFinding("high", category, "template expression evaluated (SSTI) in " + where + ": " + value + " -> " + extra));
                }
                break;
            case "crlf":
                if (r.getHeader(extra) != null) {
                    out.add(new SecurityFinding("high", category, "CRLF header injection - injected response header appeared via " + where));
                }
                break;
            default:
                break;
        }
        if (r.getStatusCode() >= 500) {
            out.add(new SecurityFinding("medium", "robustness", "payload caused a " + r.getStatusCode() + " (unhandled) in " + where));
        }
    }

    // ----- CORS: does the API reflect an arbitrary Origin (and, worse, with credentials)? -----
    private static void checkCors(ApiContext api, String m, String path, String body, List<SecurityFinding> out) {
        Map<String, String> h = new LinkedHashMap<>();
        h.put("Origin", CORS_PROBE_ORIGIN);
        Response r = send(api, m, path, body, false, null, h);
        if (r == null) {
            return;
        }
        String acao = r.getHeader("Access-Control-Allow-Origin");
        String acac = r.getHeader("Access-Control-Allow-Credentials");
        boolean creds = acac != null && acac.trim().equalsIgnoreCase("true");
        if (acao == null) {
            return;
        }
        if (acao.trim().equals(CORS_PROBE_ORIGIN)) {
            out.add(new SecurityFinding(creds ? "high" : "medium", "cors",
                    "CORS reflects an arbitrary Origin" + (creds ? " WITH credentials - any site can read authenticated responses" : " - verify the allow-list")));
        } else if ("*".equals(acao.trim()) && creds) {
            out.add(new SecurityFinding("high", "cors", "CORS returns '*' with Allow-Credentials=true (invalid but dangerous if honored)"));
        } else if ("*".equals(acao.trim())) {
            out.add(new SecurityFinding("low", "cors", "CORS allows any origin ('*') - fine for public data, not for authenticated APIs"));
        }
    }

    // ----- method surface: report advertised write methods + TRACE; send NO destructive verbs -----
    private static void checkMethods(ApiContext api, String m, String path, List<SecurityFinding> out) {
        Response opt = send(api, "OPTIONS", path, null, false, null, null);
        if (opt != null) {
            String allow = opt.getHeader("Allow");
            if (allow == null) {
                allow = opt.getHeader("Access-Control-Allow-Methods");
            }
            if (allow != null) {
                String a = allow.toUpperCase();
                List<String> writes = new ArrayList<>();
                for (String w : new String[]{"PUT", "DELETE", "PATCH"}) {
                    if (a.contains(w)) {
                        writes.add(w);
                    }
                }
                if (!writes.isEmpty()) {
                    out.add(new SecurityFinding("low", "method", "endpoint advertises write methods (" + String.join(", ", writes) + ") via OPTIONS - verify each requires proper authorization"));
                }
            }
        }
        Response trace = send(api, "TRACE", path, null, false, null, null);
        if (trace != null && is2xx(trace) && safeBody(trace).toUpperCase().contains("TRACE ")) {
            out.add(new SecurityFinding("low", "method", "TRACE is enabled and echoes the request (Cross-Site Tracing / XST)"));
        }
    }

    private static Response send(ApiContext api, String method, String path, String body,
                                 boolean stripAuth, String authOverride) {
        return send(api, method, path, body, stripAuth, authOverride, null);
    }

    private static Response send(ApiContext api, String method, String path, String body,
                                 boolean stripAuth, String authOverride, Map<String, String> extraHeaders) {
        try {
            // We build and pre-encode the URLs ourselves (injected query values are already
            // percent-encoded), so tell REST Assured NOT to re-encode - otherwise a payload like
            // %27 becomes %2527 and the target never sees the real character.
            RequestSpecification req = RestAssured.given().urlEncodingEnabled(false);
            if (api.baseUri != null && !api.baseUri.isEmpty()) {
                req = req.baseUri(api.baseUri);
            }
            for (Map.Entry<String, String> e : api.headers.entrySet()) {
                if (stripAuth && e.getKey().equalsIgnoreCase("Authorization")) {
                    continue;
                }
                req = req.header(e.getKey(), e.getValue());
            }
            if (authOverride != null) {
                req = req.header("Authorization", authOverride);
            }
            if (extraHeaders != null) {
                for (Map.Entry<String, String> e : extraHeaders.entrySet()) {
                    req = req.header(e.getKey(), e.getValue());
                }
            }
            if (body != null && !body.isEmpty()) {
                if (!api.headers.containsKey("Content-Type")) {
                    req = req.contentType("application/json");
                }
                req = req.body(api.resolve(body));
            }
            return req.request(method.toUpperCase(), api.resolve(path));
        } catch (Exception e) {
            return null;
        }
    }

    private static void checkHeaders(Response r, List<SecurityFinding> out) {
        String[][] required = {
                {"Strict-Transport-Security", "HSTS not set (allows protocol downgrade)"},
                {"X-Content-Type-Options", "X-Content-Type-Options not set (MIME sniffing)"},
                {"Content-Security-Policy", "Content-Security-Policy not set"},
                {"X-Frame-Options", "X-Frame-Options not set (clickjacking)"},
        };
        for (String[] h : required) {
            if (r.getHeader(h[0]) == null) {
                out.add(new SecurityFinding("low", "headers", h[1]));
            }
        }
        for (String leaky : new String[]{"Server", "X-Powered-By", "X-AspNet-Version"}) {
            String v = r.getHeader(leaky);
            if (v != null && v.matches(".*\\d.*")) {
                out.add(new SecurityFinding("low", "info-leak", leaky + " header reveals version: " + v));
            }
        }
        List<String> cookies = r.getHeaders().getValues("Set-Cookie");
        if (cookies != null) {
            for (String c : cookies) {
                String lc = c.toLowerCase();
                if (!lc.contains("secure") || !lc.contains("httponly")) {
                    out.add(new SecurityFinding("low", "cookie", "Set-Cookie missing Secure/HttpOnly: " + c.split(";", 2)[0]));
                }
            }
        }
    }

    private static void checkLeakage(Response r, String where, List<SecurityFinding> out) {
        String low = safeBody(r).toLowerCase();
        if (containsAny(low, STACK_SIGNS)) {
            out.add(new SecurityFinding("medium", "info-leak", "verbose error / stack trace in " + where));
        }
    }

    // ---- query-string helpers ----
    private static final Pattern STRING_FIELD = Pattern.compile("\"([^\"\\\\]{1,60})\"\\s*:\\s*\"([^\"\\\\]*)\"");

    private static List<String> queryParamNames(String path) {
        List<String> names = new ArrayList<>();
        int qi = path.indexOf('?');
        if (qi < 0 || qi == path.length() - 1) {
            return names;
        }
        for (String pair : path.substring(qi + 1).split("&")) {
            if (pair.isEmpty()) {
                continue;
            }
            int eq = pair.indexOf('=');
            String name = eq < 0 ? pair : pair.substring(0, eq);
            if (!name.isEmpty() && !names.contains(name)) {
                names.add(name);
            }
        }
        return names;
    }

    private static String setQueryParam(String path, String name, String value) {
        int qi = path.indexOf('?');
        if (qi < 0) {
            return appendParam(path, name, value);
        }
        String basePart = path.substring(0, qi);
        String[] pairs = path.substring(qi + 1).split("&");
        StringBuilder sb = new StringBuilder(basePart).append('?');
        boolean first = true;
        for (String pair : pairs) {
            if (pair.isEmpty()) {
                continue;
            }
            int eq = pair.indexOf('=');
            String pn = eq < 0 ? pair : pair.substring(0, eq);
            if (!first) {
                sb.append('&');
            }
            first = false;
            if (pn.equals(name)) {
                sb.append(pn).append('=').append(urlEncode(value));
            } else {
                sb.append(pair);
            }
        }
        return sb.toString();
    }

    private static String appendParam(String path, String name, String value) {
        return path + (path.contains("?") ? "&" : "?") + name + "=" + urlEncode(value);
    }

    private static String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
        } catch (Exception e) {
            return value;
        }
    }

    private static String jsonEscape(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n");
    }

    // Insert extra JSON fields just before the closing brace of a JSON object literal.
    private static String injectFields(String jsonObj, String fields) {
        String s = jsonObj.trim();
        int close = s.lastIndexOf('}');
        if (close < 0) {
            return s;
        }
        String inner = s.substring(0, close).trim();
        boolean empty = inner.endsWith("{");
        return inner + (empty ? "" : ",") + fields + "}";
    }

    // Restore '=' padding so a base64url segment (JWT parts drop it) decodes cleanly.
    private static String padB64(String s) {
        int rem = s.length() % 4;
        if (rem == 0) {
            return s;
        }
        StringBuilder sb = new StringBuilder(s);
        for (int i = rem; i < 4; i++) {
            sb.append('=');
        }
        return sb.toString();
    }

    private static int severityRank(String s) {
        switch (s == null ? "" : s.toLowerCase()) {
            case "high": return 3;
            case "medium": return 2;
            case "low": return 1;
            default: return 0;
        }
    }

    /**
     * How many findings are at or above a severity ('high' | 'medium' | 'low'). Used by the OPTIONAL
     * build-fail gate: the generated step asserts this is 0 only when the user opted in - the scan is
     * report-only unless you turn the gate on.
     */
    public static int countAtOrAbove(List<SecurityFinding> findings, String minSeverity) {
        int min = severityRank(minSeverity);
        int c = 0;
        for (SecurityFinding f : findings) {
            if (severityRank(f.severity) >= min) {
                c++;
            }
        }
        return c;
    }

    private static String safeBody(Response r) {
        try {
            String s = r.asString();
            return s == null ? "" : s;
        } catch (Exception e) {
            return "";
        }
    }

    private static boolean containsAny(String haystackLower, String[] needles) {
        for (String n : needles) {
            if (haystackLower.contains(n)) {
                return true;
            }
        }
        return false;
    }

    private static boolean is2xx(Response r) {
        int sc = r.getStatusCode();
        return sc >= 200 && sc < 300;
    }

    private static String repeat(String s, int n) {
        StringBuilder sb = new StringBuilder(s.length() * n);
        for (int i = 0; i < n; i++) {
            sb.append(s);
        }
        return sb.toString();
    }

    // Map a finding category to its OWASP API Security Top 10 (2023) item, for the report.
    private static String owaspTag(String category) {
        String c = category == null ? "" : category.toLowerCase();
        if (c.startsWith("injection")) {
            return "Injection";
        }
        if (c.equals("bola")) {
            return "OWASP API1:2023";
        }
        if (c.startsWith("auth/access-control")) {
            return "OWASP API1/API2:2023";
        }
        if (c.startsWith("auth/jwt")) {
            return "OWASP API2:2023";
        }
        if (c.equals("mass-assignment")) {
            return "OWASP API3:2023";
        }
        if (c.equals("robustness")) {
            return "OWASP API4:2023";
        }
        if (c.equals("method")) {
            return "OWASP API5:2023";
        }
        if (c.startsWith("graphql")) {
            return "OWASP API9:2023";
        }
        // headers, cors, transport, info-leak, cookie
        return "OWASP API8:2023";
    }

    /** Format findings for printing: highest severity first, OWASP-tagged, with a summary count line. */
    public static String report(List<SecurityFinding> findings) {
        if (findings.isEmpty()) {
            return "Security scan: no findings.";
        }
        String[] order = {"high", "medium", "low", "info"};
        StringBuilder sb = new StringBuilder();
        int high = 0, medium = 0, low = 0, info = 0;
        for (String sev : order) {
            for (SecurityFinding f : findings) {
                if (f.severity.equalsIgnoreCase(sev)) {
                    sb.append("  ").append(f.toString()).append("  [").append(owaspTag(f.category)).append("]").append(System.lineSeparator());
                }
            }
        }
        for (SecurityFinding f : findings) {
            switch (f.severity.toLowerCase()) {
                case "high": high++; break;
                case "medium": medium++; break;
                case "low": low++; break;
                default: info++; break;
            }
        }
        String head = String.format("Security scan: %d finding(s) - high=%d medium=%d low=%d info=%d",
                findings.size(), high, medium, low, info);
        return head + System.lineSeparator() + sb.toString().replaceAll("\\s+$", "");
    }
}
