reptr 0.8.0

Local-first pentest report generator: Markdown findings -> HTML/JSON/DOCX/PDF.
Documentation
use std::fs;
use std::path::Path;

use anyhow::{bail, Context, Result};
use console::style;

const REPTR_TOML_TEMPLATE: &str = r#"[engagement]
name = "{name}"
slug = "{slug}"
type = "External Web Application Penetration Test"
start_date = ""
end_date = ""
report_version = "1.0"

[client]
file = "client.toml"

[output]
formats = ["html", "json"]
directory = "output"

# Uncomment to enforce open-finding limits during build (useful in CI):
# [severity_thresholds]
# critical = 0   # fail if any critical finding is open
# high     = 5   # fail if more than 5 high findings are open
"#;

const CLIENT_TOML_TEMPLATE: &str = r#"name = "Acme Corp"
contact = "Jane Doe"
email = "security@acme.example"
"#;

const EXAMPLE_FINDING: &str = r#"---
id: F-001
title: Example Finding — Replace Me
severity: medium
cvss: "5.3"
cvss_vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"
cwe: "CWE-200"
owasp: "A01:2021"
status: open
affected_assets:
  - https://app.example.com/
tags: [example]
---

## Description

This is an example finding shipped with `reptr new`. Replace it with your own.

## Proof of Concept

```
example request / response goes here
```

## Impact

What an attacker can do because of this issue.

## Remediation

How to fix it.

## References

- https://owasp.org/Top10/
"#;

pub fn run(slug: &str) -> Result<()> {
    if slug.trim().is_empty() {
        bail!("engagement slug cannot be empty");
    }

    let root = Path::new(slug);
    if root.exists() {
        bail!(
            "`{}` already exists — refusing to overwrite",
            root.display()
        );
    }

    fs::create_dir_all(root.join("findings"))?;
    fs::create_dir_all(root.join("templates"))?;
    fs::create_dir_all(root.join("assets/screenshots"))?;
    fs::create_dir_all(root.join("output"))?;

    let name = humanize(slug);
    let reptr_toml = REPTR_TOML_TEMPLATE
        .replace("{name}", &name)
        .replace("{slug}", slug);
    fs::write(root.join("reptr.toml"), reptr_toml).with_context(|| "writing reptr.toml")?;
    fs::write(root.join("client.toml"), CLIENT_TOML_TEMPLATE)
        .with_context(|| "writing client.toml")?;
    fs::write(
        root.join("findings/001-example-finding.md"),
        EXAMPLE_FINDING,
    )
    .with_context(|| "writing example finding")?;

    println!("{} {}/", style("Created").green().bold(), slug);
    println!("  ├── reptr.toml");
    println!("  ├── client.toml");
    println!("  ├── findings/");
    println!("  │   └── 001-example-finding.md");
    println!("  ├── templates/");
    println!("  ├── assets/screenshots/");
    println!("  └── output/");
    println!();
    println!("Next: cd {slug} && reptr build");
    Ok(())
}

fn humanize(slug: &str) -> String {
    slug.split(['-', '_'])
        .filter(|s| !s.is_empty())
        .map(|s| {
            let mut chars = s.chars();
            match chars.next() {
                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}