mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-14 13:40:23 +02:00
feat: proof screenshots in reports (finding-correlated) + source-able env.sh (#37)
* feat: embed proof screenshots in reports, correlated to findings Define a convention that ties each proof image to its vulnerability and renders it in every report format. - Finding gains `screenshots: Vec<String>` (paths relative to the run workdir, e.g. evidence/<finding-id>-1.png). - Exploit prompt injects an EVIDENCE SCREENSHOTS doctrine: agents save proof PNGs into the run's absolute evidence/ dir named by a vuln slug, and list them in the finding JSON `screenshots` array. - collect_evidence() resolves whatever the agent captured (absolute, workdir-relative, evidence/, /tmp basename), copies it to a stable evidence/<finding-id>-N.png, and rewrites the field; unresolved refs are dropped so a report never embeds a missing image. - Typst (image()), HTML (<img>) and Markdown (![]) render each finding's screenshots beside its evidence. Tests: slugify + collect_evidence resolution/rename; verified a real PDF compiles with an embedded image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC * feat: source-able env.sh to activate neurosploit in the current shell Add env.sh: `source` it to export NEUROSPLOIT (binary path), NEUROSPLOIT_BASE (agents base) and prepend the binary dir to PATH — no reinstall or new terminal needed. Auto-detects the install/repo dir, honors NEUROSPLOIT_DIR, idempotent. setup.sh now writes a ready env.sh into the install dir and points users at `source <dir>/env.sh`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b55b5fa32e
commit
4eed1ce652
@@ -504,6 +504,13 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let _ = tx.send("recon: SPA/REST surface detected — applying API-hunting methodology".into()).await;
|
||||
SPA_API_DOCTRINE
|
||||
} else { "" };
|
||||
// Absolute evidence dir + the screenshot-correlation convention for the prompt.
|
||||
let evidence_dir = cfg.workdir.as_deref().map(|d| {
|
||||
let p = Path::new(d).join("evidence");
|
||||
let _ = std::fs::create_dir_all(&p);
|
||||
std::fs::canonicalize(&p).unwrap_or(p).display().to_string()
|
||||
}).unwrap_or_default();
|
||||
let shots = if evidence_dir.is_empty() { String::new() } else { screenshot_doctrine(&evidence_dir) };
|
||||
// Token economy: each agent gets a capped recon context, not the full blob.
|
||||
let recon_ctx: String = recon.chars().take(3500).collect();
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
@@ -512,6 +519,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let recon = recon_ctx.clone();
|
||||
let directives = directives.clone();
|
||||
let ops = ops.clone();
|
||||
let shots = shots.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
if pool.stop_exploiting() {
|
||||
@@ -523,9 +531,10 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let user = format!(
|
||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||
{directives}{react}{depth}{decision}{spa}{safety}{ops}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret}}. \
|
||||
{directives}{react}{depth}{decision}{spa}{safety}{ops}{doctrine}{shots}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret,screenshots}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt). \
|
||||
`screenshots` is an array of proof-image paths you saved into the evidence dir (see EVIDENCE SCREENSHOTS above); omit or leave empty when you captured none. \
|
||||
Set `auth_context` to \"authenticated\" or \"unauthenticated\"; set `account` to the test user/role you used (if any); \
|
||||
for a created test account set `secret` to its generated password (it is stored in the run vault and masked in the report).",
|
||||
target = target,
|
||||
@@ -534,6 +543,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, spa = spa, safety = SAFETY_DOCTRINE,
|
||||
ops = ops,
|
||||
doctrine = tool_doctrine(mcp_on),
|
||||
shots = shots,
|
||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||
);
|
||||
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
|
||||
@@ -1248,6 +1258,14 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
stamp_attribution(&mut findings);
|
||||
// Map findings to OWASP / MITRE / kill-chain stage for the attack graph.
|
||||
crate::attack_graph::enrich(&mut findings);
|
||||
// Collect proof screenshots into evidence/<finding-id>-N.png so the report
|
||||
// can embed each image beside its vulnerability.
|
||||
if let Some(dir) = cfg.workdir.as_deref() {
|
||||
let imgs = collect_evidence(&mut findings, Path::new(dir));
|
||||
if imgs > 0 {
|
||||
let _ = tx.send(format!("notify: 📸 {imgs} proof screenshot(s) collected → evidence/")).await;
|
||||
}
|
||||
}
|
||||
|
||||
// RL update (robust reward shaping): an agent's reward per run =
|
||||
// + strong for each CONFIRMED finding (severity × confidence),
|
||||
@@ -1391,12 +1409,128 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
|
||||
confidence: conf(o.get("confidence")),
|
||||
validated: false,
|
||||
votes: String::new(),
|
||||
screenshots: screenshot_refs(o),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Pull screenshot path(s) an agent referenced in its finding JSON. Accepts a
|
||||
/// single `screenshot` string or a `screenshots` array (any scalar coerced to
|
||||
/// string). These are raw refs (whatever the agent named/where it saved); the
|
||||
/// evidence-collection pass later resolves and renames them to stable,
|
||||
/// finding-correlated paths under the run's `evidence/` dir.
|
||||
fn screenshot_refs(o: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
match o.get("screenshots") {
|
||||
Some(serde_json::Value::Array(a)) => {
|
||||
for v in a {
|
||||
let p = match v {
|
||||
serde_json::Value::String(t) => t.trim().to_string(),
|
||||
_ => v.to_string(),
|
||||
};
|
||||
if !p.is_empty() { out.push(p); }
|
||||
}
|
||||
}
|
||||
Some(serde_json::Value::String(t)) if !t.trim().is_empty() => out.push(t.trim().to_string()),
|
||||
_ => {}
|
||||
}
|
||||
let one = s(o, "screenshot");
|
||||
if !one.is_empty() && !out.contains(&one) { out.push(one); }
|
||||
out
|
||||
}
|
||||
|
||||
/// The convention prompt that tells agents WHERE to drop proof screenshots and
|
||||
/// HOW to reference them, so each image can be correlated back to its finding.
|
||||
/// `evidence_dir` is the absolute `<workdir>/evidence` path (already created).
|
||||
fn screenshot_doctrine(evidence_dir: &str) -> String {
|
||||
format!(
|
||||
"EVIDENCE SCREENSHOTS (correlate each image to its finding):\n\
|
||||
- When you PROVE a finding visually (XSS firing, an admin panel reached, \
|
||||
data exposed, a client-side auth bypass), capture a screenshot as proof.\n\
|
||||
- Save every proof PNG into this exact directory: `{dir}` (it already exists). \
|
||||
Name each file after the vulnerability using a short kebab-case slug, e.g. \
|
||||
`{dir}/reflected-xss-search.png`, `{dir}/idor-order-42.png`.\n\
|
||||
- In that finding's JSON, add a `screenshots` array listing the file paths you saved \
|
||||
(absolute like `{dir}/idor-order-42.png`, or just the basename `idor-order-42.png`). \
|
||||
One image per distinct proof; multiple allowed. Omit the field when you have no image.\n\
|
||||
- The screenshot must belong to THAT finding — never reuse one image across unrelated findings.\n\n",
|
||||
dir = evidence_dir,
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve, dedupe and copy each finding's referenced proof screenshots into
|
||||
/// `<workdir>/evidence/<finding-id>-<n>.png`, rewriting `Finding.screenshots` to
|
||||
/// those stable, run-relative paths. Unresolved refs are dropped (so the report
|
||||
/// never embeds a missing image). Returns the number of images collected.
|
||||
fn collect_evidence(findings: &mut [Finding], workdir: &Path) -> usize {
|
||||
let evidence = workdir.join("evidence");
|
||||
if std::fs::create_dir_all(&evidence).is_err() { return 0; }
|
||||
let mut total = 0usize;
|
||||
for f in findings.iter_mut() {
|
||||
if f.screenshots.is_empty() { continue; }
|
||||
let slug = slugify(if f.id.is_empty() { &f.title } else { &f.id });
|
||||
let mut stable = Vec::new();
|
||||
let mut n = 0usize;
|
||||
for raw in f.screenshots.clone() {
|
||||
let Some(src) = resolve_screenshot(&raw, workdir, &evidence) else { continue };
|
||||
n += 1;
|
||||
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_lowercase();
|
||||
let fname = format!("{slug}-{n}.{ext}");
|
||||
let dst = evidence.join(&fname);
|
||||
// Copy unless the agent already wrote it exactly there.
|
||||
let ok = src == dst || std::fs::copy(&src, &dst).is_ok();
|
||||
if ok {
|
||||
stable.push(format!("evidence/{fname}"));
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
stable.dedup();
|
||||
f.screenshots = stable;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Find the actual file an agent referenced, trying the sensible locations a
|
||||
/// screenshot could have landed in: absolute, relative to the workdir, inside
|
||||
/// the evidence dir, or by basename in the evidence dir / `/tmp`.
|
||||
fn resolve_screenshot(raw: &str, workdir: &Path, evidence: &Path) -> Option<PathBuf> {
|
||||
let is_img = |p: &Path| p.is_file()
|
||||
&& matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(),
|
||||
Some("png" | "jpg" | "jpeg" | "webp" | "gif"));
|
||||
let base = Path::new(raw).file_name().map(PathBuf::from);
|
||||
let mut cands: Vec<PathBuf> = vec![
|
||||
PathBuf::from(raw),
|
||||
workdir.join(raw),
|
||||
evidence.join(raw),
|
||||
];
|
||||
if let Some(b) = &base {
|
||||
cands.push(evidence.join(b));
|
||||
cands.push(workdir.join(b));
|
||||
cands.push(Path::new("/tmp").join(b));
|
||||
}
|
||||
cands.into_iter().find(|p| is_img(p))
|
||||
}
|
||||
|
||||
/// Filesystem-safe kebab slug for correlating an image filename to a finding.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut dash = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
dash = false;
|
||||
} else if !dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
dash = true;
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
let slug: String = trimmed.chars().take(48).collect();
|
||||
if slug.is_empty() { "finding".into() } else { slug }
|
||||
}
|
||||
|
||||
/// Coerce any JSON scalar to a trimmed string.
|
||||
fn s(o: &serde_json::Map<String, serde_json::Value>, k: &str) -> String {
|
||||
match o.get(k) {
|
||||
@@ -1811,3 +1945,52 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t
|
||||
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
|
||||
finish(cfg, lib, "{}".into(), transcript, findings, agents, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod evidence_tests {
|
||||
use super::*;
|
||||
use crate::types::Finding;
|
||||
|
||||
fn write_png(p: &Path) {
|
||||
// Minimal valid 1x1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52,
|
||||
0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53,
|
||||
0xde,0x00,0x00,0x00,0x0c,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0x00,
|
||||
0x00,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x49,0x45,0x4e,
|
||||
0x44,0xae,0x42,0x60,0x82,
|
||||
];
|
||||
std::fs::write(p, PNG).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_correlates_filename_to_finding() {
|
||||
assert_eq!(slugify("Reflected XSS in /search?q="), "reflected-xss-in-search-q");
|
||||
assert_eq!(slugify("IDOR \u{2014} order #42"), "idor-order-42");
|
||||
assert_eq!(slugify("!!!"), "finding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_evidence_resolves_and_renames_by_finding_id() {
|
||||
let base = std::env::temp_dir().join(format!("nrs-ev-{}", std::process::id()));
|
||||
let wd = base.join("run");
|
||||
let ev = wd.join("evidence");
|
||||
std::fs::create_dir_all(&ev).unwrap();
|
||||
write_png(&ev.join("whatever-agent-named-it.png"));
|
||||
let mut fs = vec![Finding {
|
||||
id: "xss-search".into(),
|
||||
title: "Reflected XSS".into(),
|
||||
screenshots: vec!["whatever-agent-named-it.png".into()],
|
||||
..Default::default()
|
||||
}];
|
||||
let n = collect_evidence(&mut fs, &wd);
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(fs[0].screenshots, vec!["evidence/xss-search-1.png".to_string()]);
|
||||
assert!(wd.join("evidence/xss-search-1.png").is_file());
|
||||
let mut miss = vec![Finding { id: "x".into(), title: "t".into(),
|
||||
screenshots: vec!["nope.png".into()], ..Default::default() }];
|
||||
assert_eq!(collect_evidence(&mut miss, &wd), 0);
|
||||
assert!(miss[0].screenshots.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,11 +75,17 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}{review}</h3>\
|
||||
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
|
||||
<div class=m>Endpoint: {}</div>{authline}{reviewnote}\
|
||||
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>\
|
||||
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>{shots}\
|
||||
<h4>Impact</h4><p>{}</p><h4>Remediation</h4><p>{}</p></section>",
|
||||
sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title),
|
||||
esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence,
|
||||
esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation),
|
||||
shots = if f.screenshots.is_empty() { String::new() } else {
|
||||
let imgs: String = f.screenshots.iter()
|
||||
.map(|p| format!("<figure class=shot><img src=\"{}\" alt=\"proof for {}\"><figcaption>{}</figcaption></figure>",
|
||||
esc(p), esc(&f.title), esc(p))).collect();
|
||||
format!("<h4>Proof screenshots</h4><div class=shots>{imgs}</div>")
|
||||
},
|
||||
review = if needs_review(f) { " <span class=sev style=background:#8e44ad>NEEDS REVIEW</span>" } else { "" },
|
||||
reviewnote = if needs_review(f) && !f.review_reason.is_empty() {
|
||||
format!("<div class=m style=color:#8e44ad>⚠ Needs human review — {}</div>", esc(&f.review_reason))
|
||||
@@ -128,6 +134,9 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
.sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\
|
||||
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\
|
||||
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
|
||||
.shots{{display:flex;flex-wrap:wrap;gap:12px;margin:6px 0}}\
|
||||
.shot{{margin:0;max-width:100%}}.shot img{{max-width:100%;border:1px solid #e3e3e3;border-radius:8px;display:block}}\
|
||||
.shot figcaption{{color:#888;font-size:11px;margin-top:3px;font-family:ui-monospace,Menlo,monospace}}\
|
||||
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
|
||||
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
||||
<div class=meta>Asset: <b>{asset}</b> · Target: <b>{t}</b>{techline} · v3.6.5 · multi-model validated</div>\
|
||||
@@ -188,12 +197,15 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
|
||||
for f in &sorted {
|
||||
let owasp = if f.owasp.is_empty() { f.cwe.clone() } else { f.owasp.clone() };
|
||||
let status = if needs_review(f) { "needs-review" } else { "confirmed" };
|
||||
let shots = format!("({})",
|
||||
f.screenshots.iter().map(|p| format!("{},", tq(p))).collect::<String>());
|
||||
data.push_str(&format!(
|
||||
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}),\n",
|
||||
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}),\n",
|
||||
tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&owasp), tq(&f.cvss),
|
||||
tq(&f.endpoint), tq(&f.payload), tq(&f.evidence), tq(&f.impact),
|
||||
tq(&f.remediation), tq(&f.votes), f.confidence, tq(status),
|
||||
tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }),
|
||||
shots,
|
||||
));
|
||||
}
|
||||
data.push_str(")\n\n");
|
||||
@@ -280,6 +292,10 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St
|
||||
if !f.endpoint.is_empty() { s.push_str(&format!("**Endpoint:** `{}`\n\n", f.endpoint)); }
|
||||
if !f.payload.is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload)); }
|
||||
if !f.evidence.is_empty() { s.push_str(&format!("**Evidence**\n```\n{}\n```\n\n", f.evidence)); }
|
||||
if !f.screenshots.is_empty() {
|
||||
s.push_str("**Proof screenshots**\n\n");
|
||||
for p in &f.screenshots { s.push_str(&format!("\n\n", f.title.replace(']', ")"), p)); }
|
||||
}
|
||||
if !f.impact.is_empty() { s.push_str(&format!("**Impact:** {}\n\n", f.impact)); }
|
||||
if !f.remediation.is_empty() { s.push_str(&format!("**Remediation:** {}\n\n", f.remediation)); }
|
||||
s.push_str("---\n\n");
|
||||
|
||||
@@ -70,6 +70,13 @@ pub struct Finding {
|
||||
/// "below vote quorum", "no receipt", "failed adversarial refute").
|
||||
#[serde(default)]
|
||||
pub review_reason: String,
|
||||
/// Proof screenshots for this finding, as paths RELATIVE to the run workdir
|
||||
/// (e.g. "evidence/<finding-id>-1.png"). Populated by the evidence-collection
|
||||
/// pass, which resolves whatever the agent captured and copies it under the
|
||||
/// run's `evidence/` dir with a deterministic, finding-correlated name so the
|
||||
/// report can embed each image next to its vulnerability.
|
||||
#[serde(default)]
|
||||
pub screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Finding {
|
||||
@@ -100,6 +107,7 @@ impl Default for Finding {
|
||||
secret: String::new(),
|
||||
review_status: String::new(),
|
||||
review_reason: String::new(),
|
||||
screenshots: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,17 @@
|
||||
#v(4pt) #strong[Description / Impact] #linebreak() #text(9pt)[#f.impact]
|
||||
#v(4pt) #strong[Proof of Concept] #linebreak() #raw(f.payload)
|
||||
#v(3pt) #strong[Evidence] #linebreak() #raw(f.evidence)
|
||||
#let shots = f.at("screenshots", default: ())
|
||||
#if shots.len() > 0 [
|
||||
#v(4pt) #strong[Proof Screenshots]
|
||||
#for sp in shots [
|
||||
#v(3pt)
|
||||
#block(breakable: false, width: 100%)[
|
||||
#box(stroke: 0.5pt + rgb("#dddddd"), radius: 4pt, clip: true, image(sp, width: 100%))
|
||||
#v(2pt) #text(7pt, fill: gray, font: "Menlo")[#sp]
|
||||
]
|
||||
]
|
||||
]
|
||||
#v(3pt) #strong[Remediation] #linebreak() #text(9pt)[#f.remediation]
|
||||
]
|
||||
#v(8pt)
|
||||
|
||||
Reference in New Issue
Block a user