use crate::findings::{clip, field, fingerprint, list, load, run_id, truthy};
use crate::mem::cli::{add_note, append_to, link_notes, NewNote};
use crate::mem::vault::Vault;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::path::Path;
const MAX_TEXT: usize = 500;
fn class_name(cls: &str) -> String {
match cls {
"access-control" => "Broken access control",
"sqli" => "SQL injection",
"xss" => "Cross-site scripting",
"ssrf" => "Server-side request forgery",
"injection" => "Command and code injection",
"auth" => "Authentication and session flaws",
"deserialization" => "Insecure deserialization",
"path-traversal" => "Path traversal",
"secrets" => "Hardcoded secrets",
"csrf" => "Cross-site request forgery",
"xxe" => "XML external entities",
"open-redirect" => "Open redirect",
"crypto" => "Cryptographic misuse",
"race-conditions" => "Race conditions",
"file-upload" => "Unrestricted file upload",
"dependency" => "Vulnerable dependencies",
other => other,
}
.to_string()
}
fn or(f: &Value, key: &str, dflt: &str) -> String {
if truthy(f, key) {
field(f, key)
} else {
dflt.to_string()
}
}
fn quote(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_alphanumeric() || "-_./=:".contains(c))
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
struct Mem<'a> {
vault: &'a Path,
dry: bool,
cmds: std::cell::RefCell<Vec<String>>,
}
impl Mem<'_> {
fn show(&self, args: &[String]) {
let mut all = vec![
"mem".to_string(),
"--vault".into(),
self.vault.display().to_string(),
];
all.extend(args.iter().cloned());
self.cmds.borrow_mut().push(format!(
"indra {}",
all.iter().map(|a| quote(a)).collect::<Vec<_>>().join(" ")
));
}
fn exists(&self, name: &str) -> bool {
!self.dry
&& Vault::open(self.vault)
.map(|v| v.resolve(name).is_some())
.unwrap_or(false)
}
fn add(&self, n: NewNote) -> Result<(), String> {
if self.dry {
let mut a = vec!["add".to_string(), n.title.clone()];
if let Some(k) = &n.kind {
a.extend(["-t".into(), k.clone()]);
}
for t in &n.tags {
a.extend(["--tag".into(), t.clone()]);
}
for al in &n.aliases {
a.extend(["--alias".into(), al.clone()]);
}
for (r, t) in &n.rels {
a.extend(["--rel".into(), format!("{}={}", r, t)]);
}
if let Some(s) = &n.source {
a.extend(["--source".into(), s.clone()]);
}
if let Some(f) = &n.folder {
a.extend(["--folder".into(), f.clone()]);
}
if !n.body.is_empty() {
a.push(format!("--body={}", n.body));
}
a.push("--upsert".into());
self.show(&a);
return Ok(());
}
add_note(self.vault, &n).map(|_| ())
}
fn append(&self, note: &str, text: &str) -> Result<(), String> {
if self.dry {
self.show(&["append".into(), note.into(), "--".into(), text.into()]);
return Ok(());
}
append_to(self.vault, note, text).map(|_| ())
}
fn link(&self, a: &str, b: &str) -> Result<(), String> {
if self.dry {
self.show(&[
"link".into(),
a.into(),
b.into(),
"--rel".into(),
"chains-with".into(),
]);
return Ok(());
}
link_notes(self.vault, a, b, "chains-with").map(|_| ())
}
}
pub fn record(run_dir: &Path, vault: &Path, dry_run: bool) -> Result<Value, String> {
let (counts, cmds) = go(run_dir, vault, dry_run)?;
for c in cmds {
println!("{}", c);
}
Ok(counts)
}
pub fn plan(run_dir: &Path, vault: &Path) -> Result<Value, String> {
let (mut counts, cmds) = go(run_dir, vault, true)?;
counts["commands"] = json!(cmds);
counts["dry_run"] = json!(true);
Ok(counts)
}
fn go(run_dir: &Path, vault: &Path, dry_run: bool) -> Result<(Value, Vec<String>), String> {
let run = run_id(run_dir);
let findings = load(run_dir)?;
let m = Mem {
vault,
dry: dry_run,
cmds: Default::default(),
};
let mut made: HashSet<String> = HashSet::new();
let mut by_id: HashMap<String, String> = HashMap::new();
let (mut new, mut again, mut fps, mut skipped) = (0, 0, 0, 0);
let id_of = |f: &Value| field(f, "id");
for f in &findings {
let status = field(f, "status");
if !matches!(
status.as_str(),
"verified" | "needs-runtime" | "false-positive"
) {
skipped += 1;
continue;
}
let cls = or(f, "class", "unknown");
let alias = format!("fp-{}", fingerprint(f));
let loc = format!("{}:{}", or(f, "file", "?"), or(f, "line", "?"));
let ver = f.get("verification").cloned().unwrap_or(Value::Null);
if m.exists(&alias) {
m.append(
&alias,
&format!("- Seen again in {} (status: {}, {}).", run, status, loc),
)?;
by_id.insert(id_of(f), alias);
again += 1;
continue;
}
let hub = class_name(&cls);
if made.insert(hub.clone()) {
m.add(NewNote {
title: hub.clone(),
kind: Some("vuln-class".into()),
tags: vec![cls.clone()],
aliases: vec![cls.clone()],
upsert: true,
..Default::default()
})?;
}
let path = field(f, "file").replace('\\', "/").trim().to_string();
let file_hub = if path.is_empty() {
None
} else {
let title = format!("file: {}", path);
if made.insert(title.clone()) {
m.add(NewNote {
title: title.clone(),
kind: Some("file".into()),
aliases: vec![path.clone()],
folder: Some("files".into()),
body: "Code location seen in security reviews. Backlinks list what was found here.".into(),
upsert: true,
..Default::default()
})?;
}
Some(title)
};
let mut lines = vec![
format!("- status: {}", status),
format!("- location: {}", loc),
format!("- first seen: {} ({})", run, or(f, "id", "no id")),
];
if truthy(f, "cwe") {
lines.push(format!("- cwe: {}", list(f, "cwe").join(", ")));
}
if truthy(&ver, "rationale") {
lines.push(format!(
"- verifier: {}",
clip(&field(&ver, "rationale"), MAX_TEXT)
));
}
let tri = f.get("triage").cloned().unwrap_or(Value::Null);
if truthy(&tri, "to") {
let why = if truthy(&tri, "reason") {
format!(": {}", clip(&field(&tri, "reason"), MAX_TEXT))
} else {
String::new()
};
lines.push(format!(
"- triaged: {} -> {}{}",
field(&tri, "from"),
field(&tri, "to"),
why
));
}
let what = if truthy(f, "title") {
field(f, "title")
} else {
loc.clone()
};
let mut note = if status == "false-positive" {
lines.extend([
String::new(),
"Hunters: this pattern was reviewed and is not exploitable. Do not report it again unless the guard named above changes.".into(),
]);
fps += 1;
NewNote {
title: format!("False positive: {}", clip(&what, 90)),
kind: Some("learning".into()),
tags: vec![cls.clone(), "false-positive".into()],
rels: vec![("refines".into(), hub.clone())],
folder: Some("learnings".into()),
body: lines.join("\n"),
..Default::default()
}
} else {
let sev = or(f, "severity", "unknown");
if truthy(f, "why_it_matters") {
lines.push(format!(
"- impact: {}",
clip(&field(f, "why_it_matters"), MAX_TEXT)
));
}
if truthy(f, "mitigation") {
lines.push(format!(
"- mitigation: {}",
clip(&field(f, "mitigation"), MAX_TEXT)
));
}
new += 1;
NewNote {
title: format!("{}: {}", or(f, "id", &cls.to_uppercase()), clip(&what, 90)),
kind: Some("finding".into()),
tags: vec![cls.clone(), sev, status.clone()],
rels: vec![("instance-of".into(), hub.clone())],
folder: Some("findings".into()),
body: lines.join("\n"),
..Default::default()
}
};
note.aliases = vec![alias.clone()];
note.source = Some(run.clone());
if let Some(fh) = file_hub {
note.rels.push(("located-in".into(), fh));
}
note.upsert = true;
m.add(note)?;
by_id.insert(id_of(f), alias);
}
for f in &findings {
let Some(a) = by_id.get(&id_of(f)) else {
continue;
};
for other in list(f, "chained_with") {
if let Some(b) = by_id.get(&other) {
if a < b {
m.link(a, b)?;
}
}
}
}
let counts = json!({
"vault": vault.display().to_string(),
"new": new, "seen-again": again, "false-positive": fps, "skipped": skipped,
});
Ok((counts, m.cmds.into_inner()))
}