Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::labels::{Label, Labels};
use crate::process::{self, Control, Interruption};

mod advisories;
pub use advisories::{DraftAdvisory, Package};
pub use advisories::{DraftAdvisory, Package, SecurityRecords};

#[cfg(test)]
mod execution_tests;
Expand Down
110 changes: 101 additions & 9 deletions src/github/advisories.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
//! Private advisory operations. Publishing and closing belong to the Day shift.
//! Private Security finding records. Public repositories use draft advisories;
//! private repositories whose advisory endpoint is unavailable use issues.

use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::{Value, json};

use super::GitHub;
use crate::issue::IssueUrl;
use crate::labels::{Label, NEEDS_TRIAGE};

const SECURITY_FINDING: Label = Label::new(
"security-finding",
"A Security finding recorded privately for the Day shift",
);

pub enum SecurityRecords {
Advisories(Vec<Value>),
Issues(Vec<Value>),
}

impl SecurityRecords {
pub fn remember(&mut self, record: Value) {
match self {
Self::Advisories(records) | Self::Issues(records) => records.push(record),
}
}
}

#[derive(Clone, Debug, Serialize)]
pub struct Package {
Expand All @@ -21,27 +42,98 @@ pub struct DraftAdvisory {
}

impl DraftAdvisory {
pub fn already_recorded(&self, advisories: &[Value]) -> bool {
pub fn already_recorded(&self, records: &SecurityRecords) -> bool {
let marker = format!("Fingerprint: `{}`", self.fingerprint);
advisories.iter().any(|advisory| {
advisory["description"]
let (records, field) = match records {
SecurityRecords::Advisories(records) => (records, "description"),
SecurityRecords::Issues(records) => (records, "body"),
};
records.iter().any(|record| {
record[field]
.as_str()
.is_some_and(|description| description.lines().any(|line| line == marker))
})
}
}

impl GitHub {
/// Every state and every page, including closed and published records.
pub fn security_advisories(&self, repo: &str) -> Result<Vec<Value>> {
self.gh_api_items(
/// Every state and every page. A 404 selects issues only when repository
/// metadata confirms they will be private.
pub fn security_records(&self, repo: &str) -> Result<SecurityRecords> {
match self.gh_api_items(
&format!("repos/{repo}/security-advisories?per_page=100"),
"",
)
) {
Ok(records) => Ok(SecurityRecords::Advisories(records)),
Err(error) if format!("{error:#}").contains("(HTTP 404)") => {
let repository = self.gh_json(&["api", &format!("repos/{repo}")])?;
if repository["private"].as_bool() != Some(true) {
bail!(
"security advisories unavailable; refusing to record findings in public issues"
);
}
let records = self.gh_api_items(
&format!("repos/{repo}/issues?state=all&labels=security-finding&per_page=100"),
"",
)?;
Ok(SecurityRecords::Issues(
records
.into_iter()
.filter(|record| record.get("pull_request").is_none())
.collect(),
))
}
Err(error) => Err(error),
}
}

/// Use the storage selected while reading the repository's records.
pub fn create_security_record(
&self,
repo: &str,
records: &SecurityRecords,
draft: &DraftAdvisory,
) -> Result<Value> {
match records {
SecurityRecords::Advisories(_) => self.create_security_advisory(repo, draft),
SecurityRecords::Issues(_) => {
self.ensure_labels(repo, &[SECURITY_FINDING, NEEDS_TRIAGE])?;
let output = self.output_with_input(
&[
"issue",
"create",
"--repo",
repo,
"--title",
&draft.summary,
"--body-file",
"-",
"--label",
"security-finding,needs-triage",
],
Some(draft.description.as_bytes()),
)?;
if !output.status.success() {
// API errors can echo private evidence, so do not relay them.
bail!(
"creating a private Security finding issue failed ({})",
output.status
);
}
let url = std::str::from_utf8(&output.stdout)
.context("creating a private Security finding issue returned invalid UTF-8")?;
IssueUrl::parse(url.trim()).map_err(|_| {
anyhow::anyhow!(
"creating a private Security finding issue returned no issue URL"
)
})?;
Ok(json!({"body": draft.description}))
}
}
}

/// The create endpoint creates a draft. No severity or version claim.
pub fn create_security_advisory(&self, repo: &str, draft: &DraftAdvisory) -> Result<Value> {
fn create_security_advisory(&self, repo: &str, draft: &DraftAdvisory) -> Result<Value> {
let body = serde_json::to_vec(&json!({
"summary": draft.summary,
"description": draft.description,
Expand Down
38 changes: 25 additions & 13 deletions src/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use std::fmt::Display;
use std::path::PathBuf;

use crate::github::DraftAdvisory;
use crate::github::{DraftAdvisory, SecurityRecords};
use anyhow::Result;
use serde_json::Value;

Expand Down Expand Up @@ -100,10 +100,14 @@ pub trait Outside {
fn check_node(&mut self) -> Result<()>;
/// Audit the Base branch in a disposable checkout and validate its artifacts.
fn audit(&mut self, base: &str) -> (Result<crate::security::audit::Audited>, Option<PathBuf>);
/// Private records in every advisory state.
fn security_advisories(&mut self) -> Result<Vec<Value>>;
/// Record one finding privately as a draft.
fn create_security_advisory(&mut self, finding: &DraftAdvisory) -> Result<Value>;
/// Private advisory or issue records in every state.
fn security_records(&mut self) -> Result<SecurityRecords>;
/// Record one finding in the repository's private storage.
fn create_security_record(
&mut self,
records: &SecurityRecords,
finding: &DraftAdvisory,
) -> Result<Value>;
/// Run `dispatch` to its end, on the Harness the pass checked.
fn dispatch(&mut self, dispatch: Dispatch) -> Ended;
}
Expand Down Expand Up @@ -201,12 +205,16 @@ impl Outside for LaunchAndGitHub<'_> {
crate::security::audit::run(self.launch.git(), self.repo, base, self.harness)
}

fn security_advisories(&mut self) -> Result<Vec<Value>> {
GitHub::new().security_advisories(&self.repo.slug())
fn security_records(&mut self) -> Result<SecurityRecords> {
GitHub::new().security_records(&self.repo.slug())
}

fn create_security_advisory(&mut self, finding: &DraftAdvisory) -> Result<Value> {
GitHub::new().create_security_advisory(&self.repo.slug(), finding)
fn create_security_record(
&mut self,
records: &SecurityRecords,
finding: &DraftAdvisory,
) -> Result<Value> {
GitHub::new().create_security_record(&self.repo.slug(), records, finding)
}

/// The run's asks are the Architect plan's or the Ready issue's, from
Expand Down Expand Up @@ -247,7 +255,7 @@ mod in_memory {
use anyhow::{Result, anyhow, bail};

use super::{Dispatch, Outside};
use crate::github::DraftAdvisory;
use crate::github::{DraftAdvisory, SecurityRecords};
use crate::github::{Issue, ListedIssue};
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Edit, Label, Labels};
Expand Down Expand Up @@ -616,12 +624,16 @@ mod in_memory {
(audited, self.session_log.clone())
}

fn security_advisories(&mut self) -> Result<Vec<Value>> {
fn security_records(&mut self) -> Result<SecurityRecords> {
self.calls.push(Call::AdvisoryList);
Ok(self.advisories.clone())
Ok(SecurityRecords::Advisories(self.advisories.clone()))
}

fn create_security_advisory(&mut self, finding: &DraftAdvisory) -> Result<Value> {
fn create_security_record(
&mut self,
_records: &SecurityRecords,
finding: &DraftAdvisory,
) -> Result<Value> {
self.calls
.push(Call::CreateAdvisory(finding.fingerprint.clone()));
let advisory = json!({"description": finding.description, "state": "draft"});
Expand Down
7 changes: 4 additions & 3 deletions src/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,15 @@ fn audit_and_record(
if audited.findings.is_empty() {
return Ok(recorded);
}
let mut known = outside.security_advisories()?;
let mut known = outside.security_records()?;
for finding in audited.findings {
if finding.already_recorded(&known) {
recorded.existing += 1;
} else {
known.push(outside.create_security_advisory(&finding)?);
let record = outside.create_security_record(&known, &finding)?;
known.remember(record);
recorded.created += 1;
outside.step("recorded a Security finding as a private draft advisory".to_string());
outside.step("recorded a Security finding privately".to_string());
}
}
Ok(recorded)
Expand Down
57 changes: 56 additions & 1 deletion tests/fakes/gh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//!
//! ```text
//! {"repo": "owner/repo",
//! "private"?: true,
//! "advisory_error"?: "<stderr>",
//! "issues": {"<number>": "OPEN" | "CLOSED"},
//! "titles"?: {"<number>": "<title>"},
//! "created"?: {"<number>": "<ISO 8601 time>"},
Expand Down Expand Up @@ -651,6 +653,14 @@ fn issue_fields(state: &Json, n: &str, issue_state: &Json) -> Json {
("url", string(url)),
("labels", Array(labels)),
("createdAt", created),
(
"body",
state
.get("bodies")
.and_then(|bodies| bodies.get(n))
.cloned()
.unwrap_or_else(|| string("")),
),
])
}

Expand Down Expand Up @@ -808,6 +818,7 @@ fn issue_close(state: &mut Json, positional: &[String], flags: &Flags) {
/// Open an issue with `--title`, `--body` and the comma-separated `--label`s,
/// numbered one past the highest issue, and print its URL.
fn issue_create(state: &mut Json, flags: &Flags) {
use std::io::Read;
let labels: Vec<&str> = flag(flags, "label")
.map(|labels| labels.split(',').collect())
.unwrap_or_default();
Expand All @@ -827,7 +838,13 @@ fn issue_create(state: &mut Json, flags: &Flags) {
state.at_mut("issues").set(&n, string("OPEN"));
let title = flag(flags, "title").expect("no --title");
state.entry("titles", object([])).set(&n, string(title));
let body = flag(flags, "body").expect("no --body");
let mut input = String::new();
let body = if flag(flags, "body-file") == Some("-") {
std::io::stdin().read_to_string(&mut input).unwrap();
&input
} else {
flag(flags, "body").expect("no --body")
};
state.entry("bodies", object([])).set(&n, string(body));
let labels = labels.into_iter().map(string).collect();
state.entry("labels", object([])).set(&n, Array(labels));
Expand Down Expand Up @@ -951,6 +968,12 @@ fn advisory_api(state: &mut Json, positional: &[String], flags: &Flags) {
.unwrap_or((&positional[0], ""));
let (repo, rest) = repo_prefix(path).unwrap();
check_repo_is(state, Some(repo));
if let Some(error) = state.get("advisory_error") {
die(error.str(), 1);
}
if state.get("private").is_some_and(Json::truthy) {
die("gh: Not Found (HTTP 404)", 1);
}
let method = flag(flags, "method").unwrap_or("GET");
if method == "GET" {
let wanted = query
Expand Down Expand Up @@ -1017,6 +1040,38 @@ fn advisory_api(state: &mut Json, positional: &[String], flags: &Flags) {

fn api(state: &mut Json, positional: &[String], flags: &Flags) {
let path = &positional[0];
if path == &format!("repos/{}", state.at("repo").str()) {
println!(
"{}",
object([(
"private",
Bool(state.get("private").is_some_and(Json::truthy))
)])
);
return;
}
if let Some((repo, rest)) = repo_prefix(path)
&& rest.starts_with("issues?")
{
check_repo_is(state, Some(repo));
assert!(rest.contains("state=all"));
assert!(rest.contains("labels=security-finding"));
assert!(rest.contains("per_page=100"));
assert!(flags.contains_key("paginate"));
assert_eq!(flag(flags, "jq"), Some(".[]"));
let Json::Object(issues) = state.at("issues") else {
panic!("issues is an object")
};
for (n, issue_state) in issues {
if issue_labels(state, n)
.iter()
.any(|label| label.str().eq_ignore_ascii_case("security-finding"))
{
println!("{}", issue_fields(state, n, issue_state));
}
}
return;
}
if path.contains("/security-advisories") {
advisory_api(state, positional, flags);
return;
Expand Down
Loading
Loading