airs-cli 0.7.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! The self-contained HTML of an environment report (TS `environment-render.ts`,
//! DESIGN §15.17): a hash-based Content Security Policy over the inline style and script,
//! `&#<code>;` escaping of `&<>"'`, the evidence tables and the scope section. The bytes
//! are TypeScript's.

use super::{Cell, Report, Table};
use sha2::{Digest, Sha256};

/// The inline style (TS `CSS`, with its leading and trailing newline).
pub const CSS: &str = concat!(
    "\n",
    r#":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}}"#,
    "\n"
);

/// The inline script (TS `JS`).
pub const JS: &str = r#"'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'});"#;

/// `html(v)`: `String(v ?? 'Unknown')` with `&<>"'` as `&#<code>;`.
pub fn escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for c in text.chars() {
        match c {
            '&' | '<' | '>' | '"' | '\'' => out.push_str(&format!("&#{};", u32::from(c))),
            c => out.push(c),
        }
    }
    out
}

/// A cell as `String(v)`, escaped.
fn cell(value: &Cell) -> String {
    match value {
        Cell::Text(text) => escape(text),
        Cell::Count(count) => count.to_string(),
    }
}

/// `base64(sha256(text))`.
fn hash(text: &str) -> String {
    crate::redteam::args::base64(&Sha256::digest(text.as_bytes()))
}

/// The CSP of the document (`default-src 'none'; script-src 'sha256-…'; …`).
pub fn policy() -> String {
    format!(
        "default-src 'none'; script-src 'sha256-{}'; style-src 'sha256-{}'; connect-src 'none'; base-uri 'none'; form-action 'none'",
        hash(JS),
        hash(CSS)
    )
}

fn section(index: usize, table: &Table) -> String {
    let title = escape(&table.title);
    let body = if table.rows.is_empty() {
        "<p>No rows collected; consult source completeness before interpreting this as empty.</p>"
            .to_owned()
    } else {
        let headers: String = table
            .headers
            .iter()
            .map(|header| format!("<th scope=\"col\">{}</th>", escape(header)))
            .collect();
        let rows: String = table
            .rows
            .iter()
            .map(|row| {
                let cells: String = row
                    .iter()
                    .map(|value| format!("<td>{}</td>", cell(value)))
                    .collect();
                format!("<tr>{cells}</tr>")
            })
            .collect();
        format!(
            "<div class=\"table-wrap\" role=\"region\" aria-label=\"{title}\" tabindex=\"0\"><table><caption>{} aggregate rows</caption><thead><tr>{headers}</tr></thead><tbody>{rows}</tbody></table></div>",
            table.rows.len()
        )
    };
    format!(
        "<section id=\"table-{index}\"><h2>{title}</h2><p class=\"note\">{}</p>{body}</section>",
        escape(&table.note)
    )
}

/// `renderEnvironmentReportHtml`, ending in one newline.
pub fn render(report: &Report) -> String {
    let sections: String = report
        .all_tables()
        .iter()
        .enumerate()
        .map(|(index, table)| section(index, table))
        .collect();
    let findings: String = report
        .findings
        .iter()
        .map(|finding| {
            format!(
                "<article class=\"finding\" data-priority=\"{}\"><h3>{}</h3><p>{}</p><p><strong>Next step:</strong> {}</p><p class=\"note\">{}</p></article>",
                escape(finding.priority.name()),
                escape(&finding.title),
                escape(&finding.evidence),
                escape(&finding.recommendation),
                escape(&finding.source)
            )
        })
        .collect();
    let findings = if findings.is_empty() {
        "<p>No findings in available evidence; this does not establish absence of risk.</p>"
            .to_owned()
    } else {
        findings
    };
    let limitations: String = super::LIMITATIONS
        .iter()
        .map(|limitation| format!("<li>{}</li>", escape(limitation)))
        .collect();
    let title = escape(&report.title);
    format!(
        concat!(
            "<!doctype html>\n",
            "<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=\"{policy}\"><meta name=\"referrer\" content=\"no-referrer\">",
            "<title>{title}</title><style>{css}</style></head><body><a class=\"skip\" href=\"#main\">Skip to report</a>",
            "<header><p>{product}</p><h1>{title}</h1><span class=\"badge\">{health}</span>",
            "<p>Collection: {started} → {generated}</p><p>Scan creation window (UTC): {start} → {end}</p>",
            "<p>Read-only evidence · 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>{findings}</section>",
            "{sections}<section id=\"scope\"><h2>Scope and limitations</h2><ul>{limitations}</ul></section>",
            "<footer>Prisma AIRS CLI · Red Team report schema 1 · Works offline and without JavaScript.</footer></main>",
            "<script>{js}</script></body></html>\n"
        ),
        policy = escape(&policy()),
        title = title,
        css = CSS,
        product = escape(super::PRODUCT),
        health = report.health.label(),
        started = escape(&report.collection_started_at),
        generated = escape(&report.generated_at),
        start = escape(&report.window_start),
        end = escape(&report.window_end),
        findings = findings,
        sections = sections,
        limitations = limitations,
        js = JS,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The CSP hashes of the TypeScript reference (computed with Node's crypto over the
    /// same CSS and JS).
    #[test]
    fn the_policy_hashes_the_inline_assets_as_typescript_does() {
        assert_eq!(
            policy(),
            "default-src 'none'; script-src 'sha256-vxcVmn+OlgkxEN2VQ7q63vmVAZ/uCr6B1goVcd7EK6c='; style-src 'sha256-jPa0PJ+rVzL3fveVWR9DWnRPrxPWaM+zQ/+OnWDs2+A='; connect-src 'none'; base-uri 'none'; form-action 'none'"
        );
        assert_eq!(CSS.len(), 1701);
        assert_eq!(JS.len(), 438);
    }

    #[test]
    fn text_is_escaped_as_numeric_references() {
        assert_eq!(
            escape("</script><img src=x onerror=alert('1')>&\""),
            "&#60;/script&#62;&#60;img src=x onerror=alert(&#39;1&#39;)&#62;&#38;&#34;"
        );
    }
}