@cdot65/prisma-airs-cli 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2483,6 +2483,433 @@ function computeCategoryBreakdown(results) {
2483
2483
  })).sort((a, b) => b.errorRate - a.errorRate);
2484
2484
  }
2485
2485
 
2486
+ // src/reports/aigateway.ts
2487
+ function record(v) {
2488
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid object");
2489
+ return v;
2490
+ }
2491
+ function numeric(v, integer = false) {
2492
+ if (v === null) return null;
2493
+ if (typeof v !== "number" || !Number.isFinite(v) || integer && (!Number.isSafeInteger(v) || v < 0))
2494
+ throw new Error("Invalid number");
2495
+ return v;
2496
+ }
2497
+ function count(v) {
2498
+ const n = numeric(v, true);
2499
+ if (n === null) throw new Error("Missing count");
2500
+ return n;
2501
+ }
2502
+ var CHARTS = {
2503
+ requests: ["total"],
2504
+ errors: ["total"],
2505
+ users: ["total"],
2506
+ cost: ["total", "avg"],
2507
+ latency: ["total", "p50", "p90", "p99"],
2508
+ tokens: ["total", "total_request_units", "total_response_units"],
2509
+ cacheSummary: [
2510
+ "summary.cacheHits",
2511
+ "summary.avgCacheLatency",
2512
+ "summary.totalRequests",
2513
+ "summary.cacheSpeedup"
2514
+ ],
2515
+ cacheHitTrend: ["summary.totalCacheHits", "summary.hitRate"],
2516
+ userTrends: ["summary.total", "summary.unique", "summary.avg"],
2517
+ errorTrends: ["summary.errorPercent"],
2518
+ errorCategoryTrends: ["summary.totalErrors", "summary.totalUniqueErrorCodes"],
2519
+ groupedErrors: ["total"],
2520
+ rescuedRetries: ["total", "retryTotal", "fallbackTotal"],
2521
+ feedbackTrend: ["total"],
2522
+ feedbackWeighted: ["total"],
2523
+ feedbackScoreDistribution: ["total"],
2524
+ feedbackModels: []
2525
+ };
2526
+ async function collectGatewayEnvironmentReport(client, options) {
2527
+ const maxPages = options.maxPages ?? 40;
2528
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1 || maxPages > 100)
2529
+ throw new Error("maxPages must be 1\u2013100");
2530
+ if (!options.workspace.trim() || !options.tsgId.trim() || !Number.isFinite(options.start.getTime()) || !Number.isFinite(options.end.getTime()) || options.start >= options.end)
2531
+ throw new Error("Invalid report scope");
2532
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
2533
+ const report = {
2534
+ schemaVersion: 1,
2535
+ product: "Prisma AIRS AI Gateway",
2536
+ schemaLabel: "AI Gateway",
2537
+ windowLabel: "Telemetry window (UTC)",
2538
+ title: options.title ?? "AI Gateway daily report",
2539
+ collectionStartedAt: now().toISOString(),
2540
+ generatedAt: "",
2541
+ window: { start: options.start.toISOString(), end: options.end.toISOString() },
2542
+ health: "unknown",
2543
+ sources: [],
2544
+ findings: [],
2545
+ tables: [],
2546
+ limitations: [
2547
+ "Read-only operational evidence, not a security certification or availability SLA. No inference or configuration changes are performed.",
2548
+ "All telemetry uses the displayed fixed window. Configuration and organisation/catalog reads describe current state, not daily changes.",
2549
+ "Requests, errors, feedback and log totals are independent server aggregates, not an atomic snapshot. An AIRS block is not necessarily an outage.",
2550
+ "Cost values are cents; latency values are milliseconds. Feedback scores may be negative. Null means unknown, not zero.",
2551
+ "Only aggregate numbers and HTTP status codes are retained. Keys, user identities, workspace names/IDs, metadata, URLs, prompts, raw logs and configuration bodies are excluded.",
2552
+ "Catalog entries describe available capabilities, not enabled protection. Organisation limit sentinel values are not interpreted as quota exhaustion.",
2553
+ "Incomplete sources and page budgets are explicit. Current inventory lists have no verified continuation contract; total/has_more mismatches are partial."
2554
+ ]
2555
+ };
2556
+ const metrics2 = {
2557
+ title: "Telemetry metrics",
2558
+ note: "Server field names are preserved. cost: cents; latency and avgCacheLatency: ms; errorPercent: percent. Feedback is not a request count.",
2559
+ headers: ["Source", "Metric", "Value"],
2560
+ rows: []
2561
+ };
2562
+ const trends = {
2563
+ title: "Error timeline",
2564
+ note: "Server UTC buckets; counts by HTTP status. Not a count of unique violating sessions.",
2565
+ headers: ["UTC bucket", "HTTP status", "Errors"],
2566
+ rows: []
2567
+ };
2568
+ const statuses = {
2569
+ title: "Error categories",
2570
+ note: "Server-reported errors by HTTP status; 446 denotes an AIRS block.",
2571
+ headers: ["HTTP status", "Errors"],
2572
+ rows: []
2573
+ };
2574
+ const inventory = {
2575
+ title: "Current configuration and capabilities",
2576
+ note: "Aggregate counts only. Active tenant workspaces are tenant-wide; keys and configs use the selected workspace.",
2577
+ headers: ["Source", "Metric", "Value"],
2578
+ rows: []
2579
+ };
2580
+ report.tables.push(metrics2, statuses, trends, inventory);
2581
+ async function source2(name, window2, collect2) {
2582
+ const s = {
2583
+ name,
2584
+ method: name,
2585
+ window: window2,
2586
+ status: "complete",
2587
+ records: 0,
2588
+ pages: 0,
2589
+ notes: []
2590
+ };
2591
+ report.sources.push(s);
2592
+ try {
2593
+ await collect2(s);
2594
+ } catch (error) {
2595
+ s.status = s.records ? "partial" : "unavailable";
2596
+ const code = error?.statusCode;
2597
+ s.notes.push(
2598
+ typeof code === "number" && Number.isInteger(code) && code >= 400 && code <= 599 ? `HTTP ${code}: request failed; check access and service availability.` : "Request, scope or response validation failed; upstream details omitted."
2599
+ );
2600
+ }
2601
+ }
2602
+ function envelope(v, s) {
2603
+ const r = record(v);
2604
+ if (r.success !== true) throw new Error("Unsuccessful response");
2605
+ const d = record(r.data);
2606
+ if (d.isQuotaExceeded !== false) {
2607
+ s.status = "partial";
2608
+ s.notes.push("Quota exceeded or quota state unknown; aggregates may be incomplete.");
2609
+ }
2610
+ return d;
2611
+ }
2612
+ function list(v, s) {
2613
+ const r = record(v);
2614
+ if (r.success === false || !Array.isArray(r.data)) throw new Error("Invalid list");
2615
+ const rows = r.data.map(record);
2616
+ const total2 = count(r.total);
2617
+ if (r.has_more === true || total2 !== rows.length) {
2618
+ s.status = "partial";
2619
+ s.notes.push("List is truncated or total differs; continuation is not documented.");
2620
+ }
2621
+ s.records = rows.length;
2622
+ s.pages = 1;
2623
+ return rows;
2624
+ }
2625
+ let workspaceId;
2626
+ let workspaceSlug;
2627
+ await source2("workspaces.list", "Current active tenant workspaces (admin)", async (s) => {
2628
+ const rows = list(await client.workspaces.list({ plane: "admin" }), s);
2629
+ inventory.rows.push([s.name, "Active workspaces returned", rows.length]);
2630
+ const exact = rows.filter((r) => r.id === options.workspace || r.slug === options.workspace);
2631
+ const matches = exact.length ? exact : rows.filter((r) => r.name === options.workspace);
2632
+ if (matches.length !== 1 || typeof matches[0]?.id !== "string" || typeof matches[0]?.slug !== "string")
2633
+ throw new Error("Workspace missing or ambiguous");
2634
+ workspaceId = matches[0].id;
2635
+ workspaceSlug = matches[0].slug;
2636
+ });
2637
+ const window = () => {
2638
+ if (!workspaceSlug) throw new Error("Workspace unresolved");
2639
+ return { workspaceSlug, start: new Date(options.start), end: new Date(options.end) };
2640
+ };
2641
+ const scope = () => {
2642
+ if (!workspaceId) throw new Error("Workspace unresolved");
2643
+ return { workspaceId };
2644
+ };
2645
+ for (const [method, fields] of Object.entries(CHARTS)) {
2646
+ await source2(`telemetry.${method}`, "Displayed telemetry window", async (s) => {
2647
+ const d = envelope(await client.telemetry[method](window()), s);
2648
+ const rows = fields.map((field) => {
2649
+ const value = field.split(".").reduce((v, key) => record(v)[key], d);
2650
+ const n = numeric(value);
2651
+ const signed = method.startsWith("feedback");
2652
+ const fractional = signed || method === "cost" || method === "latency" || field.endsWith("avg") || field.endsWith("Latency") || field.endsWith("Speedup") || field.endsWith("Rate") || field.endsWith("Percent");
2653
+ if (n !== null && (!signed && n < 0 || !fractional && !Number.isSafeInteger(n)))
2654
+ throw new Error("Invalid metric");
2655
+ return [method, field, n];
2656
+ });
2657
+ const buckets = d.records ?? d.trend;
2658
+ if (buckets !== void 0 && !Array.isArray(buckets)) throw new Error("Invalid buckets");
2659
+ s.records = Array.isArray(buckets) ? buckets.length : 1;
2660
+ s.pages = 1;
2661
+ if (method === "feedbackModels") rows.push([method, "Models with feedback", s.records]);
2662
+ if (method === "errorCategoryTrends") {
2663
+ const entries = d.trend.map((v) => {
2664
+ const r = record(v);
2665
+ return [count(r.response_status_code), count(r.count)];
2666
+ });
2667
+ statuses.rows.push(...entries);
2668
+ if (entries.some(([code, n]) => code >= 200 && code < 300 && n > 0))
2669
+ report.findings.push({
2670
+ priority: "review",
2671
+ title: "Success-status responses appear in error analytics",
2672
+ evidence: "The server classifies some HTTP 2xx responses as errors. Error analytics are not identical to non-2xx transaction counts.",
2673
+ recommendation: "Inspect the underlying application/provider error classifications before calculating an HTTP failure rate.",
2674
+ source: s.name
2675
+ });
2676
+ if (entries.some(([code, n]) => n > 0 && (code === 429 || code >= 500)))
2677
+ report.findings.push({
2678
+ priority: "attention",
2679
+ title: "Rate limiting or server errors observed",
2680
+ evidence: "Error categories include HTTP 429 or 5xx responses in the selected window.",
2681
+ recommendation: "Review provider availability, quotas and retry policies; correlate with transaction logs.",
2682
+ source: s.name
2683
+ });
2684
+ else if (entries.some(([, n]) => n > 0))
2685
+ report.findings.push({
2686
+ priority: "review",
2687
+ title: "Gateway errors or policy blocks observed",
2688
+ evidence: "Nonzero error-category counts were returned.",
2689
+ recommendation: "Review status categories; distinguish expected AIRS blocks from integration failures.",
2690
+ source: s.name
2691
+ });
2692
+ }
2693
+ if (method === "groupedErrors") {
2694
+ const entries = [];
2695
+ for (const v of d.trend) {
2696
+ const r = record(v);
2697
+ if (typeof r.x !== "string" || !Number.isFinite(Date.parse(r.x)) || !Array.isArray(r.y))
2698
+ throw new Error("Invalid trend");
2699
+ for (const v2 of r.y) {
2700
+ const y = record(v2);
2701
+ entries.push([
2702
+ new Date(r.x).toISOString(),
2703
+ count(y.response_status_code),
2704
+ count(y.count)
2705
+ ]);
2706
+ }
2707
+ }
2708
+ trends.rows.push(...entries);
2709
+ }
2710
+ metrics2.rows.push(...rows);
2711
+ });
2712
+ }
2713
+ await source2("telemetry.filterBoundaries", "Displayed telemetry window", async (s) => {
2714
+ const d = envelope(await client.telemetry.filterBoundaries(window()), s);
2715
+ for (const key of ["unique_ai_models", "unique_status_codes"]) {
2716
+ if (!Array.isArray(d[key])) throw new Error("Invalid filter values");
2717
+ metrics2.rows.push(["filterBoundaries", `${key} count`, d[key].length]);
2718
+ }
2719
+ s.records = 1;
2720
+ s.pages = 1;
2721
+ });
2722
+ const logs = {
2723
+ title: "Transaction coverage",
2724
+ note: "Unique collected transactions by HTTP status; only complete pagination represents the entire window. Raw rows are excluded.",
2725
+ headers: ["Metric", "Value"],
2726
+ rows: []
2727
+ };
2728
+ report.tables.push(logs);
2729
+ await source2("telemetry.logs", "Displayed telemetry window", async (s) => {
2730
+ const ids = /* @__PURE__ */ new Set();
2731
+ const codes = /* @__PURE__ */ new Map();
2732
+ let expected;
2733
+ try {
2734
+ for (let currentPage = 0; currentPage < maxPages; currentPage++) {
2735
+ const d = envelope(
2736
+ await client.telemetry.logs({ ...window(), pageSize: 50, currentPage }),
2737
+ s
2738
+ );
2739
+ s.pages++;
2740
+ const total2 = count(d.total);
2741
+ expected ??= total2;
2742
+ if (total2 !== expected || !Array.isArray(d.records) || d.records.length > 50)
2743
+ throw new Error("Unstable page");
2744
+ for (const v of d.records) {
2745
+ const r = record(v);
2746
+ if (typeof r.id !== "string" || !r.id || ids.has(r.id) || r.workspace_slug !== workspaceSlug)
2747
+ throw new Error("Invalid identity");
2748
+ const timestamp = typeof r.created_at === "string" ? Date.parse(r.created_at) : NaN;
2749
+ if (!Number.isFinite(timestamp) || timestamp < options.start.getTime() || timestamp > options.end.getTime())
2750
+ throw new Error("Outside window");
2751
+ const code = count(r.response_status_code);
2752
+ ids.add(r.id);
2753
+ codes.set(code, (codes.get(code) ?? 0) + 1);
2754
+ s.records = ids.size;
2755
+ }
2756
+ if (ids.size > expected) throw new Error("Unexpected total");
2757
+ if (ids.size === expected) return;
2758
+ if (!d.records.length) throw new Error("Premature empty page");
2759
+ }
2760
+ s.status = "partial";
2761
+ s.notes.push(
2762
+ "Transaction page budget reached. Increase --max-pages to collect the remaining rows."
2763
+ );
2764
+ } finally {
2765
+ logs.rows.push(["Server total", expected ?? null], ["Unique collected", ids.size]);
2766
+ for (const [code, n] of [...codes].sort(([a], [b]) => a - b))
2767
+ logs.rows.push([`HTTP ${code}`, n]);
2768
+ }
2769
+ });
2770
+ for (const method of ["configs.list", "apiKeys.listService", "apiKeys.listUser"]) {
2771
+ await source2(method, "Current selected workspace configuration", async (s) => {
2772
+ const response = method === "configs.list" ? await client.configs.list(scope()) : method === "apiKeys.listService" ? await client.apiKeys.listService(scope()) : await client.apiKeys.listUser(scope());
2773
+ const rows = list(response, s);
2774
+ inventory.rows.push([method, "Rows returned", rows.length]);
2775
+ });
2776
+ }
2777
+ await source2("organisations.getInfo", "Current tenant configuration", async (s) => {
2778
+ const r = await client.organisations.getInfo(options.tsgId);
2779
+ inventory.rows.push([
2780
+ s.name,
2781
+ "Configured limit categories",
2782
+ Object.keys(r.benefits.limits).length
2783
+ ]);
2784
+ s.records = 1;
2785
+ s.pages = 1;
2786
+ });
2787
+ await source2(
2788
+ "guardrails.getCatalog",
2789
+ "Current tenant capability catalog (not enabled guardrails)",
2790
+ async (s) => {
2791
+ const r = await client.guardrails.getCatalog();
2792
+ inventory.rows.push([s.name, "Available evaluators", r.evals.length]);
2793
+ s.records = r.evals.length;
2794
+ s.pages = 1;
2795
+ }
2796
+ );
2797
+ for (const s of report.sources.filter((s2) => s2.status !== "complete"))
2798
+ report.findings.push({
2799
+ priority: "review",
2800
+ title: `Incomplete evidence: ${s.name}`,
2801
+ evidence: s.notes.join(" "),
2802
+ recommendation: "Resolve source access or validation problems and rerun. Do not interpret missing metrics as zero.",
2803
+ source: s.name
2804
+ });
2805
+ const telemetryAvailable = report.sources.some(
2806
+ (s) => s.name.startsWith("telemetry.") && s.status === "complete"
2807
+ );
2808
+ report.health = !telemetryAvailable ? "unknown" : report.findings.some((f) => f.priority === "attention") ? "attention" : report.findings.length ? "review" : "no-findings";
2809
+ report.generatedAt = now().toISOString();
2810
+ return report;
2811
+ }
2812
+
2813
+ // src/reports/environment-render.ts
2814
+ import { createHash } from "crypto";
2815
+ var HEALTH = {
2816
+ attention: "Attention required",
2817
+ review: "Review required",
2818
+ "no-findings": "No findings in available evidence",
2819
+ unknown: "Unable to assess"
2820
+ };
2821
+ var html = (v) => String(v ?? "Unknown").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
2822
+ var md = (v) => String(v ?? "Unknown").replace(
2823
+ /[&<>"'\\`*_[\]{}()|!#~\r\n]/g,
2824
+ (c) => /[\r\n]/.test(c) ? " " : `&#${c.charCodeAt(0)};`
2825
+ );
2826
+ function tables(r) {
2827
+ return [
2828
+ ...r.tables,
2829
+ {
2830
+ title: "Evidence and completeness",
2831
+ note: "Complete means the source was collected within its budget, not that the environment is secure. Unavailable is not zero.",
2832
+ headers: ["Source", "SDK method", "Window", "Status", "Records", "Pages", "Notes"],
2833
+ rows: r.sources.map((s) => [
2834
+ s.name,
2835
+ s.method,
2836
+ s.window,
2837
+ s.status,
2838
+ s.records,
2839
+ s.pages,
2840
+ s.notes.join(" ") || "Collection completed."
2841
+ ])
2842
+ }
2843
+ ];
2844
+ }
2845
+ function renderEnvironmentReportMarkdown(r) {
2846
+ const lines = [
2847
+ `# ${md(r.title)}`,
2848
+ "",
2849
+ `**${md(r.product)}**`,
2850
+ "",
2851
+ `Assessment: **${HEALTH[r.health]}**`,
2852
+ "",
2853
+ `Collection: ${r.collectionStartedAt} \u2192 ${r.generatedAt}`,
2854
+ "",
2855
+ `${md(r.windowLabel ?? "Scan creation window (UTC)")}: ${r.window.start} \u2192 ${r.window.end}`,
2856
+ "",
2857
+ "## What needs attention",
2858
+ ""
2859
+ ];
2860
+ for (const f of r.findings)
2861
+ lines.push(
2862
+ `### ${f.priority.toUpperCase()}: ${md(f.title)}`,
2863
+ "",
2864
+ `Evidence: ${md(f.evidence)}`,
2865
+ "",
2866
+ `Next step: ${md(f.recommendation)}`,
2867
+ "",
2868
+ `Source: ${md(f.source)}`,
2869
+ ""
2870
+ );
2871
+ if (!r.findings.length)
2872
+ lines.push("No findings in available evidence; this does not establish absence of risk.", "");
2873
+ for (const t of tables(r)) {
2874
+ lines.push(`## ${md(t.title)}`, "", md(t.note), "");
2875
+ if (t.rows.length)
2876
+ lines.push(
2877
+ `| ${t.headers.map(md).join(" | ")} |`,
2878
+ `| ${t.headers.map(() => "---").join(" | ")} |`,
2879
+ ...t.rows.map((row) => `| ${row.map(md).join(" | ")} |`),
2880
+ ""
2881
+ );
2882
+ else
2883
+ lines.push(
2884
+ "No rows collected; consult source completeness before interpreting this as empty.",
2885
+ ""
2886
+ );
2887
+ }
2888
+ lines.push(
2889
+ "## Scope and limitations",
2890
+ "",
2891
+ ...r.limitations.map((v) => `- ${md(v)}`),
2892
+ "",
2893
+ `Prisma AIRS CLI \xB7 ${md(r.schemaLabel ?? "Red Team")} report schema ${r.schemaVersion}`,
2894
+ ""
2895
+ );
2896
+ return lines.join("\n");
2897
+ }
2898
+ var CSS = `
2899
+ :root{font:16px/1.6 system-ui,sans-serif;color:#193449;background:#f0f4f7}*{box-sizing:border-box}body{margin:0}header{background:#152e43;color:white;padding:3rem max(1rem,calc((100vw - 1160px)/2))}h1{font-size:clamp(1.8rem,4vw,3rem);line-height:1.15}h1,h2,h3,p,td{overflow-wrap:anywhere}main{max-width:1200px;margin:auto;padding:1rem}section,article{padding:1.4rem;background:white;border:1px solid #cddbe4;border-radius:10px;margin:1rem 0}.finding{border-left:5px solid #b08528}.finding[data-priority=attention]{border-left-color:#b1392d}.note{color:#536575;font-size:.9rem}.badge{border:1px solid;padding:.3rem .8rem;border-radius:30px;display:inline-block}nav,.controls{display:flex;gap:1rem;flex-wrap:wrap}button,select{font:inherit;padding:.4rem;border:1px solid #526778;border-radius:5px;background:white;color:#193449}a{color:#006b63}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;text-align:left}th,td{padding:.7rem;border-bottom:1px solid #d4dfe6;vertical-align:top}th{background:#edf3f7}caption{text-align:left}footer{padding:1rem;color:#536575}.js-only{display:none}.ready .js-only{display:flex}[hidden]{display:none!important}:focus-visible{outline:3px solid #ae6816;outline-offset:3px}.skip{position:absolute;left:-10000px}.skip:focus{left:1rem;top:1rem;background:white;padding:1rem}@media(max-width:600px){header{padding:1.5rem 1rem}section{padding:1rem}th,td{padding:.5rem}}@media print{body{background:white;font-size:10pt}header{background:white;color:black;padding:0}main{max-width:none;padding:0}nav,.controls,.skip{display:none!important}[hidden]{display:revert!important}.table-wrap{overflow:visible}section{border:0;padding:0}article{break-inside:avoid}}
2900
+ `;
2901
+ var JS = `'use strict';document.documentElement.classList.add('ready');document.getElementById('print').addEventListener('click',function(){window.print()});document.getElementById('priority').addEventListener('change',function(){var n=0;document.querySelectorAll('.finding').forEach(function(e){e.hidden=this.value!=='all'&&e.dataset.priority!==this.value;if(!e.hidden)n++},this);document.getElementById('count').textContent=n+' findings shown'});`;
2902
+ function renderEnvironmentReportHtml(r) {
2903
+ const hash = (v) => createHash("sha256").update(v).digest("base64");
2904
+ const policy = `default-src 'none'; script-src 'sha256-${hash(JS)}'; style-src 'sha256-${hash(CSS)}'; connect-src 'none'; base-uri 'none'; form-action 'none'`;
2905
+ const sections = tables(r).map(
2906
+ (t, i) => `<section id="table-${i}"><h2>${html(t.title)}</h2><p class="note">${html(t.note)}</p>${t.rows.length ? `<div class="table-wrap" role="region" aria-label="${html(t.title)}" tabindex="0"><table><caption>${t.rows.length} aggregate rows</caption><thead><tr>${t.headers.map((v) => `<th scope="col">${html(v)}</th>`).join("")}</tr></thead><tbody>${t.rows.map((row) => `<tr>${row.map((v) => `<td>${html(v)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>` : "<p>No rows collected; consult source completeness before interpreting this as empty.</p>"}</section>`
2907
+ ).join("");
2908
+ return `<!doctype html>
2909
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${html(policy)}"><meta name="referrer" content="no-referrer"><title>${html(r.title)}</title><style>${CSS}</style></head><body><a class="skip" href="#main">Skip to report</a><header><p>${html(r.product)}</p><h1>${html(r.title)}</h1><span class="badge">${HEALTH[r.health]}</span><p>Collection: ${html(r.collectionStartedAt)} \u2192 ${html(r.generatedAt)}</p><p>${html(r.windowLabel ?? "Scan creation window (UTC)")}: ${html(r.window.start)} \u2192 ${html(r.window.end)}</p><p>Read-only evidence \xB7 Confidential environment metadata</p></header><main id="main"><nav aria-label="Report sections"><a href="#findings">Attention</a><a href="#table-0">Evidence tables</a><a href="#scope">Scope</a></nav><section id="findings"><h2>What needs attention</h2><div class="controls js-only"><label for="priority">Priority</label><select id="priority"><option value="all">All</option><option value="attention">Attention</option><option value="review">Review</option></select><span id="count" role="status"></span><button id="print" type="button">Print / save PDF</button></div>${r.findings.map((f) => `<article class="finding" data-priority="${html(f.priority)}"><h3>${html(f.title)}</h3><p>${html(f.evidence)}</p><p><strong>Next step:</strong> ${html(f.recommendation)}</p><p class="note">${html(f.source)}</p></article>`).join("") || "<p>No findings in available evidence; this does not establish absence of risk.</p>"}</section>${sections}<section id="scope"><h2>Scope and limitations</h2><ul>${r.limitations.map((v) => `<li>${html(v)}</li>`).join("")}</ul></section><footer>Prisma AIRS CLI \xB7 ${html(r.schemaLabel ?? "Red Team")} report schema ${r.schemaVersion} \xB7 Works offline and without JavaScript.</footer></main><script>${JS}</script></body></html>
2910
+ `;
2911
+ }
2912
+
2486
2913
  // src/reports/io.ts
2487
2914
  import { randomUUID } from "crypto";
2488
2915
  import { link, open, unlink } from "fs/promises";
@@ -2505,7 +2932,7 @@ async function writeReportFile(path2, content) {
2505
2932
  var DataError = class extends Error {
2506
2933
  };
2507
2934
  var label = (value) => typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 120) : "Unknown";
2508
- function count(value) {
2935
+ function count2(value) {
2509
2936
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
2510
2937
  throw new DataError("A required counter is missing or invalid.");
2511
2938
  return value;
@@ -2581,7 +3008,7 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2581
3008
  const r = await read(rows.length);
2582
3009
  s.pages++;
2583
3010
  if (!Array.isArray(r.data)) throw new DataError("Missing inventory array.");
2584
- const reported = count(r.pagination.total_items);
3011
+ const reported = count2(r.pagination.total_items);
2585
3012
  total2 ??= reported;
2586
3013
  if (total2 !== reported) throw new DataError("Inventory changed during pagination.");
2587
3014
  if (r.data.length > 15) throw new DataError("Page exceeds requested size.");
@@ -2615,7 +3042,7 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2615
3042
  }
2616
3043
  await source2("Overview", "getDashboardOverview()", "Current dashboard snapshot", async (s) => {
2617
3044
  const r = await client.getDashboardOverview();
2618
- const total2 = count(r.total_targets);
3045
+ const total2 = count2(r.total_targets);
2619
3046
  table("Dashboard overview", s.window, ["Metric", "Count"], [["Targets", total2]]);
2620
3047
  s.records = 1;
2621
3048
  s.pages = 1;
@@ -2627,11 +3054,11 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2627
3054
  async (s) => {
2628
3055
  const r = await client.getScanStatistics();
2629
3056
  const rows = [
2630
- ["Scans", count(r.total_scans)],
2631
- ["Targets scanned", count(r.targets_scanned)]
3057
+ ["Scans", count2(r.total_scans)],
3058
+ ["Targets scanned", count2(r.targets_scanned)]
2632
3059
  ];
2633
3060
  for (const risk of r.risk_profile ?? []) {
2634
- const n = count(risk.total);
3061
+ const n = count2(risk.total);
2635
3062
  rows.push([`Risk: ${label(risk.risk_rating)}`, n]);
2636
3063
  if (/^(high|critical)$/i.test(risk.risk_rating) && n > 0)
2637
3064
  finding(
@@ -2643,7 +3070,7 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2643
3070
  );
2644
3071
  }
2645
3072
  for (const status of r.scan_status ?? [])
2646
- rows.push([`Status: ${label(status.name)}`, count(status.count)]);
3073
+ rows.push([`Status: ${label(status.name)}`, count2(status.count)]);
2647
3074
  table("Dashboard scan statistics", s.window, ["Metric", "Count"], rows);
2648
3075
  s.records = 1;
2649
3076
  s.pages = 1;
@@ -2667,8 +3094,8 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2667
3094
  const rows = [];
2668
3095
  for (const kind of ["static", "dynamic", "custom"]) {
2669
3096
  const q = r[kind];
2670
- const allocated = count(q.allocated);
2671
- const consumed = count(q.consumed);
3097
+ const allocated = count2(q.allocated);
3098
+ const consumed = count2(q.consumed);
2672
3099
  if (typeof q.unlimited !== "boolean") throw new DataError("Missing unlimited quota flag.");
2673
3100
  rows.push([
2674
3101
  kind,
@@ -2750,8 +3177,8 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2750
3177
  "Current channel snapshot",
2751
3178
  async (s) => {
2752
3179
  const r = await client.networkBroker.getChannelStats();
2753
- const total2 = count(r.total_channels);
2754
- const online = count(r.online_channels);
3180
+ const total2 = count2(r.total_channels);
3181
+ const online = count2(r.online_channels);
2755
3182
  if (online > total2) throw new DataError("Online channel count exceeds total.");
2756
3183
  table(
2757
3184
  "Network broker",
@@ -2797,103 +3224,11 @@ async function collectRedTeamEnvironmentReport(client, options = {}) {
2797
3224
  }
2798
3225
 
2799
3226
  // src/reports/redteam-render.ts
2800
- import { createHash } from "crypto";
2801
- var HEALTH = {
2802
- attention: "Attention required",
2803
- review: "Review required",
2804
- "no-findings": "No findings in available evidence",
2805
- unknown: "Unable to assess"
2806
- };
2807
- var html = (v) => String(v ?? "Unknown").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
2808
- var md = (v) => String(v ?? "Unknown").replace(
2809
- /[&<>"'\\`*_[\]{}()|!#~\r\n]/g,
2810
- (c) => /[\r\n]/.test(c) ? " " : `&#${c.charCodeAt(0)};`
2811
- );
2812
- function tables(r) {
2813
- return [
2814
- ...r.tables,
2815
- {
2816
- title: "Evidence and completeness",
2817
- note: "Complete means the source was collected within its budget, not that the environment is secure. Unavailable is not zero.",
2818
- headers: ["Source", "SDK method", "Window", "Status", "Records", "Pages", "Notes"],
2819
- rows: r.sources.map((s) => [
2820
- s.name,
2821
- s.method,
2822
- s.window,
2823
- s.status,
2824
- s.records,
2825
- s.pages,
2826
- s.notes.join(" ") || "Collection completed."
2827
- ])
2828
- }
2829
- ];
2830
- }
2831
- function renderRedTeamReportMarkdown(r) {
2832
- const lines = [
2833
- `# ${md(r.title)}`,
2834
- "",
2835
- `**${r.product}**`,
2836
- "",
2837
- `Assessment: **${HEALTH[r.health]}**`,
2838
- "",
2839
- `Collection: ${r.collectionStartedAt} \u2192 ${r.generatedAt}`,
2840
- "",
2841
- `Scan creation window (UTC): ${r.window.start} \u2192 ${r.window.end}`,
2842
- "",
2843
- "## What needs attention",
2844
- ""
2845
- ];
2846
- for (const f of r.findings)
2847
- lines.push(
2848
- `### ${f.priority.toUpperCase()}: ${md(f.title)}`,
2849
- "",
2850
- `Evidence: ${md(f.evidence)}`,
2851
- "",
2852
- `Next step: ${md(f.recommendation)}`,
2853
- "",
2854
- `Source: ${md(f.source)}`,
2855
- ""
2856
- );
2857
- if (!r.findings.length)
2858
- lines.push("No findings in available evidence; this does not establish absence of risk.", "");
2859
- for (const t of tables(r)) {
2860
- lines.push(`## ${md(t.title)}`, "", md(t.note), "");
2861
- if (t.rows.length)
2862
- lines.push(
2863
- `| ${t.headers.map(md).join(" | ")} |`,
2864
- `| ${t.headers.map(() => "---").join(" | ")} |`,
2865
- ...t.rows.map((row) => `| ${row.map(md).join(" | ")} |`),
2866
- ""
2867
- );
2868
- else
2869
- lines.push(
2870
- "No rows collected; consult source completeness before interpreting this as empty.",
2871
- ""
2872
- );
2873
- }
2874
- lines.push(
2875
- "## Scope and limitations",
2876
- "",
2877
- ...r.limitations.map((v) => `- ${md(v)}`),
2878
- "",
2879
- `Prisma AIRS CLI \xB7 Red Team report schema ${r.schemaVersion}`,
2880
- ""
2881
- );
2882
- return lines.join("\n");
3227
+ function renderRedTeamReportMarkdown(report) {
3228
+ return renderEnvironmentReportMarkdown(report);
2883
3229
  }
2884
- var CSS = `
2885
- :root{font:16px/1.6 system-ui,sans-serif;color:#193449;background:#f0f4f7}*{box-sizing:border-box}body{margin:0}header{background:#152e43;color:white;padding:3rem max(1rem,calc((100vw - 1160px)/2))}h1{font-size:clamp(1.8rem,4vw,3rem);line-height:1.15}h1,h2,h3,p,td{overflow-wrap:anywhere}main{max-width:1200px;margin:auto;padding:1rem}section,article{padding:1.4rem;background:white;border:1px solid #cddbe4;border-radius:10px;margin:1rem 0}.finding{border-left:5px solid #b08528}.finding[data-priority=attention]{border-left-color:#b1392d}.note{color:#536575;font-size:.9rem}.badge{border:1px solid;padding:.3rem .8rem;border-radius:30px;display:inline-block}nav,.controls{display:flex;gap:1rem;flex-wrap:wrap}button,select{font:inherit;padding:.4rem;border:1px solid #526778;border-radius:5px;background:white;color:#193449}a{color:#006b63}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;text-align:left}th,td{padding:.7rem;border-bottom:1px solid #d4dfe6;vertical-align:top}th{background:#edf3f7}caption{text-align:left}footer{padding:1rem;color:#536575}.js-only{display:none}.ready .js-only{display:flex}[hidden]{display:none!important}:focus-visible{outline:3px solid #ae6816;outline-offset:3px}.skip{position:absolute;left:-10000px}.skip:focus{left:1rem;top:1rem;background:white;padding:1rem}@media(max-width:600px){header{padding:1.5rem 1rem}section{padding:1rem}th,td{padding:.5rem}}@media print{body{background:white;font-size:10pt}header{background:white;color:black;padding:0}main{max-width:none;padding:0}nav,.controls,.skip{display:none!important}[hidden]{display:revert!important}.table-wrap{overflow:visible}section{border:0;padding:0}article{break-inside:avoid}}
2886
- `;
2887
- var JS = `'use strict';document.documentElement.classList.add('ready');document.getElementById('print').addEventListener('click',function(){window.print()});document.getElementById('priority').addEventListener('change',function(){var n=0;document.querySelectorAll('.finding').forEach(function(e){e.hidden=this.value!=='all'&&e.dataset.priority!==this.value;if(!e.hidden)n++},this);document.getElementById('count').textContent=n+' findings shown'});`;
2888
- function renderRedTeamReportHtml(r) {
2889
- const hash = (v) => createHash("sha256").update(v).digest("base64");
2890
- const policy = `default-src 'none'; script-src 'sha256-${hash(JS)}'; style-src 'sha256-${hash(CSS)}'; connect-src 'none'; base-uri 'none'; form-action 'none'`;
2891
- const sections = tables(r).map(
2892
- (t, i) => `<section id="table-${i}"><h2>${html(t.title)}</h2><p class="note">${html(t.note)}</p>${t.rows.length ? `<div class="table-wrap" role="region" aria-label="${html(t.title)}" tabindex="0"><table><caption>${t.rows.length} aggregate rows</caption><thead><tr>${t.headers.map((v) => `<th scope="col">${html(v)}</th>`).join("")}</tr></thead><tbody>${t.rows.map((row) => `<tr>${row.map((v) => `<td>${html(v)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>` : "<p>No rows collected; consult source completeness before interpreting this as empty.</p>"}</section>`
2893
- ).join("");
2894
- return `<!doctype html>
2895
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${html(policy)}"><meta name="referrer" content="no-referrer"><title>${html(r.title)}</title><style>${CSS}</style></head><body><a class="skip" href="#main">Skip to report</a><header><p>${r.product}</p><h1>${html(r.title)}</h1><span class="badge">${HEALTH[r.health]}</span><p>Collection: ${html(r.collectionStartedAt)} \u2192 ${html(r.generatedAt)}</p><p>Scan creation window (UTC): ${html(r.window.start)} \u2192 ${html(r.window.end)}</p><p>Read-only evidence \xB7 Confidential environment metadata</p></header><main id="main"><nav aria-label="Report sections"><a href="#findings">Attention</a><a href="#table-0">Evidence tables</a><a href="#scope">Scope</a></nav><section id="findings"><h2>What needs attention</h2><div class="controls js-only"><label for="priority">Priority</label><select id="priority"><option value="all">All</option><option value="attention">Attention</option><option value="review">Review</option></select><span id="count" role="status"></span><button id="print" type="button">Print / save PDF</button></div>${r.findings.map((f) => `<article class="finding" data-priority="${html(f.priority)}"><h3>${html(f.title)}</h3><p>${html(f.evidence)}</p><p><strong>Next step:</strong> ${html(f.recommendation)}</p><p class="note">${html(f.source)}</p></article>`).join("") || "<p>No findings in available evidence; this does not establish absence of risk.</p>"}</section>${sections}<section id="scope"><h2>Scope and limitations</h2><ul>${r.limitations.map((v) => `<li>${html(v)}</li>`).join("")}</ul></section><footer>Prisma AIRS CLI \xB7 Red Team report schema ${r.schemaVersion} \xB7 Works offline and without JavaScript.</footer></main><script>${JS}</script></body></html>
2896
- `;
3230
+ function renderRedTeamReportHtml(report) {
3231
+ return renderEnvironmentReportHtml(report);
2897
3232
  }
2898
3233
 
2899
3234
  // src/reports/render.ts
@@ -3176,7 +3511,7 @@ function safeFailure(error) {
3176
3511
  function label2(value) {
3177
3512
  return (value?.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim() || "Unknown").slice(0, 240);
3178
3513
  }
3179
- function count2(value) {
3514
+ function count3(value) {
3180
3515
  return Number.isSafeInteger(value) && value >= 0 ? value : null;
3181
3516
  }
3182
3517
  async function collect(source2, maxPages, read, identity) {
@@ -3226,7 +3561,7 @@ function inventoryNext(next, size, offset) {
3226
3561
  function total(values) {
3227
3562
  if (values.some((value) => value === null)) return null;
3228
3563
  const result = values.reduce((sum, value) => sum + (value ?? 0), 0);
3229
- return count2(result);
3564
+ return count3(result);
3230
3565
  }
3231
3566
  function distribution(rows) {
3232
3567
  const groups = /* @__PURE__ */ new Map();
@@ -3238,11 +3573,11 @@ function distribution(rows) {
3238
3573
  }
3239
3574
  function severity(value) {
3240
3575
  return {
3241
- critical: count2(value.critical),
3242
- high: count2(value.high),
3243
- medium: count2(value.medium),
3244
- low: count2(value.low),
3245
- total: count2(value.total)
3576
+ critical: count3(value.critical),
3577
+ high: count3(value.high),
3578
+ medium: count3(value.medium),
3579
+ low: count3(value.low),
3580
+ total: count3(value.total)
3246
3581
  };
3247
3582
  }
3248
3583
  async function collectRuntimeDailyReport(client, options = {}) {
@@ -3305,7 +3640,7 @@ async function collectRuntimeDailyReport(client, options = {}) {
3305
3640
  offset
3306
3641
  });
3307
3642
  const size = page.items?.length ?? 0;
3308
- const totalItems = count2(page.pagination?.total_items);
3643
+ const totalItems = count3(page.pagination?.total_items);
3309
3644
  if (totalItems !== null) {
3310
3645
  if (expectedAppTotal !== void 0 && totalItems !== expectedAppTotal)
3311
3646
  throw new ReportDataError(
@@ -3357,7 +3692,7 @@ async function collectRuntimeDailyReport(client, options = {}) {
3357
3692
  async (offset) => {
3358
3693
  const page = await client.dashboard.sessionsOverview({ ...daily, limit: 25, offset });
3359
3694
  const size = page.items?.length ?? 0;
3360
- const totalItems = count2(page.pagination?.total_items);
3695
+ const totalItems = count3(page.pagination?.total_items);
3361
3696
  if (totalItems === null || page.pagination?.skip !== offset || page.pagination?.limit !== 25)
3362
3697
  throw new ReportDataError("Missing or mismatched session pagination metadata.");
3363
3698
  if (expectedSessionTotal !== void 0 && expectedSessionTotal !== totalItems)
@@ -3414,8 +3749,8 @@ async function collectRuntimeDailyReport(client, options = {}) {
3414
3749
  const appIds = new Set(registered.map((app) => app.customer_appId));
3415
3750
  const reportApps = applications.map((app) => ({
3416
3751
  name: label2(app.name),
3417
- sessions: count2(app.sessions_total),
3418
- violatingSessions: count2(app.sessions_violated),
3752
+ sessions: count3(app.sessions_total),
3753
+ violatingSessions: count3(app.sessions_violated),
3419
3754
  registered: app.id && appIds.has(app.id) ? true : appSource.status === "complete" && app.id ? false : null
3420
3755
  })).sort(
3421
3756
  (a, b) => (b.violatingSessions ?? -1) - (a.violatingSessions ?? -1) || a.name.localeCompare(b.name)
@@ -3492,7 +3827,7 @@ async function collectRuntimeDailyReport(client, options = {}) {
3492
3827
  );
3493
3828
  return {
3494
3829
  name,
3495
- revision: count2(profile.revision),
3830
+ revision: count3(profile.revision),
3496
3831
  active: profile.active ?? null,
3497
3832
  modified: profile.last_modified_ts && Number.isFinite(Date.parse(profile.last_modified_ts)) ? new Date(profile.last_modified_ts).toISOString() : null,
3498
3833
  timeoutActions,
@@ -3540,8 +3875,8 @@ async function collectRuntimeDailyReport(client, options = {}) {
3540
3875
  "Review these sessions in SCM and check integration error handling. This is not a tenant-wide failure rate.",
3541
3876
  sessionSource.name
3542
3877
  );
3543
- const chartSessions = chartSource.status === "unavailable" ? null : total(chart.map((bucket) => count2(bucket.total)));
3544
- const chartViolatingSessions = chartSource.status === "unavailable" ? null : total(chart.map((bucket) => count2(bucket.violating)));
3878
+ const chartSessions = chartSource.status === "unavailable" ? null : total(chart.map((bucket) => count3(bucket.total)));
3879
+ const chartViolatingSessions = chartSource.status === "unavailable" ? null : total(chart.map((bucket) => count3(bucket.violating)));
3545
3880
  if (chartSessions !== null && sessionSource.status === "complete" && chartSessions !== rawSessions.length)
3546
3881
  add(
3547
3882
  "review",
@@ -3552,7 +3887,7 @@ async function collectRuntimeDailyReport(client, options = {}) {
3552
3887
  );
3553
3888
  const urgentViolations = total(
3554
3889
  trend.map(
3555
- (bucket) => total([count2(bucket.violation_breakdown.critical), count2(bucket.violation_breakdown.high)])
3890
+ (bucket) => total([count3(bucket.violation_breakdown.critical), count3(bucket.violation_breakdown.high)])
3556
3891
  )
3557
3892
  );
3558
3893
  if ((urgentViolations ?? 0) > 0)
@@ -3606,17 +3941,17 @@ async function collectRuntimeDailyReport(client, options = {}) {
3606
3941
  violatingSessions: chartViolatingSessions,
3607
3942
  buckets: chart.map((bucket) => ({
3608
3943
  time: label2(bucket.time),
3609
- sessions: count2(bucket.total),
3610
- violatingSessions: count2(bucket.violating),
3944
+ sessions: count3(bucket.total),
3945
+ violatingSessions: count3(bucket.violating),
3611
3946
  violations: severity(bucket.violation_breakdown)
3612
3947
  }))
3613
3948
  },
3614
3949
  topApplications: rankings.map((app) => ({
3615
3950
  name: label2(app.name),
3616
- violations: count2(app.total_violations),
3951
+ violations: count3(app.total_violations),
3617
3952
  detectors: app.policy_violations.map((detector) => ({
3618
3953
  name: label2(detector.detection_type),
3619
- count: count2(detector.total)
3954
+ count: count3(detector.total)
3620
3955
  }))
3621
3956
  })),
3622
3957
  violationTrend: trend.map((bucket) => ({
@@ -3662,6 +3997,9 @@ export {
3662
3997
  validateTopic,
3663
3998
  computeMetrics,
3664
3999
  computeCategoryBreakdown,
4000
+ collectGatewayEnvironmentReport,
4001
+ renderEnvironmentReportMarkdown,
4002
+ renderEnvironmentReportHtml,
3665
4003
  writeReportFile,
3666
4004
  collectRedTeamEnvironmentReport,
3667
4005
  renderRedTeamReportMarkdown,