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, SessionFailure};
use crate::issue::{IssueUrl, Repo};
use crate::review::PullRequestUrl;
use crate::session::{Kind, Purpose, Sessions};
use crate::worktree::{SkillWorktree, Worktree};
use crate::{logs, progress, prompt};
pub(crate) mod step;
pub(crate) fn run(
sessions: &Sessions<'_>,
worktree: &Worktree,
issue: &IssueUrl,
base: &str,
text: &str,
) -> Result<Review> {
let merge_base = worktree.merge_base_commit(base)?;
run_in(sessions, issue, &merge_base, text, || {
worktree.head()?;
Ok(worktree.path())
})
}
pub(crate) fn run_on_pull_request(
sessions: &Sessions<'_>,
worktree: &SkillWorktree,
pr: &PullRequestUrl,
base_commit: &str,
text: &str,
) -> Result<Review> {
let merge_base = worktree.merge_base_commit(base_commit)?;
run_in(sessions, &pr.issue, &merge_base, text, || {
worktree.head()?;
Ok(worktree.path())
})
}
fn run_in<'a>(
sessions: &Sessions<'_>,
issue: &IssueUrl,
merge_base: &str,
text: &str,
checkout: impl Fn() -> Result<&'a Path>,
) -> Result<Review> {
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)?;
let worktree = checkout()?;
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);
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(),
})
}
}
const MAX_REPORT_BYTES: u64 = 1024 * 1024;
const MIN_FRAGMENT_CHARS: usize = 8;
pub(crate) struct PrivateReportMaterial {
fragments: Vec<String>,
}
impl PrivateReportMaterial {
pub(crate) fn exposes(&self, text: &str) -> bool {
self.fragments
.iter()
.any(|fragment| text.contains(fragment))
}
}
pub(crate) fn private_report_material(repo: &Repo) -> PrivateReportMaterial {
let mut fragments = Vec::new();
let root = logs::root(repo).join("security-reviews");
if is_real_dir(&root)
&& let Ok(entries) = fs::read_dir(&root)
{
for entry in entries.flatten() {
let path = entry.path();
if is_real_dir(&path) {
collect_report(&path.join("pre-existing.json"), &mut fragments);
}
}
}
PrivateReportMaterial { fragments }
}
fn is_real_dir(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
}
fn collect_report(report: &Path, fragments: &mut Vec<String>) {
let Some(bytes) = read_regular_report(report) else {
return;
};
let path = report.to_string_lossy();
push_fragment(fragments, &path);
let Ok(findings) = serde_json::from_slice::<Vec<OldFinding>>(&bytes) else {
return;
};
for finding in findings {
let proof = finding.proof_of_concept;
push_fragment(fragments, &finding.fingerprint);
push_fragment(fragments, &finding.title);
push_fragment(fragments, &finding.description);
push_fragment(fragments, &proof.test);
push_fragment(fragments, &proof.notes);
}
}
fn push_fragment(fragments: &mut Vec<String>, text: &str) {
let text = text.trim();
for fragment in std::iter::once(text).chain(text.lines().filter(|line| *line != text)) {
let fragment = fragment.trim();
if fragment.chars().count() < MIN_FRAGMENT_CHARS {
continue;
}
if let Ok(encoded) = serde_json::to_string(fragment)
&& let Some(escaped) = encoded
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
&& escaped != fragment
&& escaped.chars().count() >= MIN_FRAGMENT_CHARS
{
fragments.push(escaped.to_string());
}
fragments.push(fragment.to_string());
}
}
fn read_regular_report(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)
.ok()?;
let meta = file.metadata().ok()?;
if !meta.file_type().is_file() || meta.len() > MAX_REPORT_BYTES {
return None;
}
let mut bytes = Vec::new();
file.take(MAX_REPORT_BYTES).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
#[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),
HarnessFailed {
harness: &'static str,
diagnostic: String,
},
}
impl Outcome {
pub fn unaddressed_count(&self) -> usize {
match &self.ended {
Ended::Complete(review) => review.findings.len(),
Ended::Incomplete(_) | Ended::HarnessFailed { .. } => 1,
}
}
pub fn of(result: Result<Review>, log: Option<PathBuf>) -> Self {
let ended = match result {
Ok(review) => Ended::Complete(review),
Err(error) => {
if let Some(refusal) = error.downcast_ref::<SafeguardRefusal>() {
Ended::Incomplete(format!("Security review refused: {refusal}"))
} else if let Some(incomplete) = error.downcast_ref::<Incomplete>() {
Ended::Incomplete(format!("Security review incomplete: {incomplete}"))
} else if let Some(failure) = error.downcast_ref::<SessionFailure>()
&& let Some(diagnostic) = failure
.diagnostic
.as_deref()
.and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
{
Ended::HarnessFailed {
harness: failure.harness,
diagnostic: diagnostic.to_string(),
}
} else {
Ended::Incomplete("Security review incomplete: session failed, ended early or omitted a valid final line; see Session trace".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(),
Ended::HarnessFailed {
harness,
diagnostic,
} => format!("Security review on {harness} failed: {diagnostic}"),
};
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"),
Ended::HarnessFailed { harness, .. } => {
format!("- Security review on {harness} failed; see Session trace\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;