use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::{fmt, fs};
use super::reproduction::{FixSize, Outcome as ReproductionOutcome, Reproduction, Severity};
use crate::github::{DraftAdvisory, FindingDraft, FindingProvenance, GitHub};
use crate::harness::interpretation::SafeguardRefusal;
use crate::issue::IssueUrl;
use crate::session::{Kind, Purpose, Sessions};
use crate::worktree::Worktree;
use crate::{logs, progress, prompt};
pub(crate) fn run(
sessions: &Sessions<'_>,
worktree: &Worktree,
issue: &IssueUrl,
base: &str,
text: &str,
) -> Result<Review> {
let merge_base = worktree.merge_base_commit(base)?;
let root = logs::root(&issue.repo()).join("security-reviews");
fs::create_dir_all(&root)?;
let directory = tempfile::Builder::new()
.prefix(&format!("{}-{}-", issue.number, logs::stamp()))
.tempdir_in(root)?
.keep();
let report = directory.join("pre-existing.json");
let text = text
.replace(
prompt::SECURITY_REVIEW_REPORT_FILE,
&report.to_string_lossy(),
)
.replace(prompt::SECURITY_REVIEW_MERGE_BASE, &merge_base);
progress::step(format!(
"Security review private report: {}",
report.display()
));
let message =
sessions.run_to_final_message(Purpose::SecurityReview, Kind::SecurityReview, &text)?;
worktree.head()?;
let review = Review::from_final_message(message.as_deref());
if let Err(error) = &review
&& !error.is::<Incomplete>()
{
return review;
}
let findings: Vec<OldFinding> = serde_json::from_slice(&fs::read(report)?)?;
if let Ok(review) = &review
&& findings.len() != review.pre_existing_count
{
bail!("Security review's private report does not match its final count");
}
let findings = findings
.into_iter()
.map(|finding| {
let reproduction = finding.reproduction()?;
Ok((finding, reproduction))
})
.collect::<Result<Vec<_>>>()?;
if !findings.is_empty() {
let github = GitHub::new();
let repo = issue.repo_slug();
let mut known = github.security_records(&repo)?;
let package = super::audit::package_in(worktree.path());
for (finding, reproduction) in findings {
let draft = DraftAdvisory::new(FindingDraft {
fingerprint: &finding.fingerprint,
summary: &finding.title,
audited_commit: &merge_base,
provenance: FindingProvenance::Review {
issue_url: &issue.url,
},
original_description: &finding.description,
evidence: &serde_json::to_string_pretty(&finding)?,
package: package.clone(),
});
let resolved = known.record_or_reuse(&draft, |records, draft| {
github.create_security_record(&repo, records, draft)
})?;
if resolved.created {
github.update_security_record(&repo, &resolved.record, &reproduction)?;
progress::step("recorded a pre-existing Security finding privately");
}
}
}
review
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct OldFinding {
fingerprint: String,
title: String,
description: String,
proof_of_concept: ProofOfConcept,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct ProofOfConcept {
test: String,
command: String,
head_exit_code: i32,
merge_base_exit_code: i32,
notes: String,
severity: String,
fix_size: String,
}
impl OldFinding {
fn reproduction(&self) -> Result<Reproduction> {
let proof = &self.proof_of_concept;
if [
&self.fingerprint,
&self.title,
&self.description,
&proof.test,
&proof.command,
&proof.notes,
]
.iter()
.any(|text| text.trim().is_empty())
|| self.fingerprint.contains(['\r', '\n', '`'])
|| self.title.contains(['\r', '\n'])
|| proof.head_exit_code <= 0
|| proof.merge_base_exit_code <= 0
{
bail!(
"Security review's private report lacks a valid old finding and failing proof-of-concept runs"
);
}
let severity = Severity::parse(&proof.severity)
.context("Security review's private report has an invalid reproduced severity")?;
let size = match proof.fix_size.as_str() {
"single" => FixSize::Single,
"spec" => FixSize::Spec,
_ => bail!("Security review's private report has an invalid reproduced fix size"),
};
Ok(Reproduction {
outcome: ReproductionOutcome::Reproduced { severity, size },
notes: format!(
"Command: {}\nHEAD exit code: {}\nMerge base exit code: {}\n\n{}",
proof.command, proof.head_exit_code, proof.merge_base_exit_code, proof.notes
),
test: proof.test.clone(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Ask {
Allow,
Forbid,
}
pub(crate) struct Outcome {
ended: Ended,
log: Option<PathBuf>,
}
enum Ended {
Complete(Review),
Incomplete(String),
}
impl Outcome {
pub fn of(result: Result<Review>, log: Option<PathBuf>) -> Self {
let ended = match result {
Ok(review) => Ended::Complete(review),
Err(error) => Ended::Incomplete(
if let Some(refusal) = error.downcast_ref::<SafeguardRefusal>() {
format!("Security review refused: {refusal}")
} else if let Some(incomplete) = error.downcast_ref::<Incomplete>() {
format!("Security review incomplete: {incomplete}")
} else {
"Security review incomplete: session failed, ended early or omitted a valid final line; see Session log".to_string()
},
),
};
Self { ended, log }
}
pub fn hold(&self) -> Option<Hold> {
let reason = match &self.ended {
Ended::Complete(review) if review.findings.is_empty() => return None,
Ended::Complete(review) => format!(
"Security review left {} unaddressed introduced finding(s): {}",
review.findings.len(),
review.findings.join("; ")
),
Ended::Incomplete(reason) => reason.clone(),
};
Some(Hold {
reason,
log: self.log.clone(),
})
}
pub fn entry(&self) -> String {
match &self.ended {
Ended::Complete(review) if review.findings.is_empty() => {
"No unaddressed introduced findings.".to_string()
}
Ended::Complete(review) => review
.findings
.iter()
.map(|title| format!("- {title}\n"))
.collect(),
Ended::Incomplete(reason) => format!("- {reason}\n"),
}
}
}
#[derive(Debug)]
pub(crate) struct Hold {
reason: String,
log: Option<PathBuf>,
}
impl Hold {
pub fn log(&self) -> Option<&Path> {
self.log.as_deref()
}
}
impl fmt::Display for Hold {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.reason)
}
}
impl std::error::Error for Hold {}
#[derive(Debug)]
struct Incomplete(String);
impl fmt::Display for Incomplete {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Incomplete {}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Review {
unaddressed_count: usize,
pub findings: Vec<String>,
pre_existing_count: usize,
}
impl Review {
pub fn from_final_message(message: Option<&str>) -> Result<Self> {
let json = message
.and_then(|message| message.lines().rfind(|line| !line.trim().is_empty()))
.and_then(|line| line.strip_prefix("Security review: "))
.context("Security review ended without its required final line")?;
if let Some(reason) = json.strip_prefix("incomplete: ") {
if reason.trim().is_empty() || reason.contains(['\r', '\n']) {
bail!("Security review ended with an invalid incomplete reason");
}
return Err(Incomplete(reason.trim().chars().take(500).collect()).into());
}
let review: Self = serde_json::from_str(json)
.context("Security review ended with an invalid final line")?;
if review.unaddressed_count != review.findings.len()
|| review
.findings
.iter()
.any(|title| title.trim().is_empty() || title.contains(['\r', '\n']))
{
bail!("Security review ended with an inconsistent finding count or title");
}
Ok(review)
}
}
#[cfg(test)]
mod tests;