Skip to content

Commit

Permalink
apiserver: Add /report endpoint for CIS reporting
Browse files Browse the repository at this point in the history
This adds a new `/report` endpoint. GETs to this endpoint get a list of
the available reports which can then be called directly. Initially this
is only `/report/cis` that can be used to trigger a bloodhound CIS
benchmark report, returning the results. The type argument controls
which report to run. Right now there is only the CIS report, but this
will be expanded in the future as we add other compliance or benchmark
reports.

```
/report
[{"name":"cis","description":"CIS Bottlerocket Benchmark"}]
```

The bloodhound options for `--level` and `--format` are supported with:

```
/report/cis&level=2&format=json"
```

Signed-off-by: Sean McGinnis <[email protected]>
  • Loading branch information
stmcginnis committed Jun 27, 2023
1 parent d5c1756 commit 867d912
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 1 deletion.
1 change: 1 addition & 0 deletions sources/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sources/api/apiserver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ serde_json = "1"
simplelog = "0.12"
snafu = "0.7"
thar-be-updates = { path = "../thar-be-updates", version = "0.1" }
tokio = { version = "~1.25", default-features = false, features = ["process"] }
walkdir = "2"

[build-dependencies]
Expand Down
16 changes: 16 additions & 0 deletions sources/api/apiserver/src/server/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ pub enum Error {
#[snafu(display("Failed to reboot, exit code: {}, stderr: {}", exit_code, stderr))]
Reboot { exit_code: i32, stderr: String },

#[snafu(display("Unable to generate report: {}", source))]
ReportExec { source: io::Error },

#[snafu(display(
"Failed to generate report, exit code: {}, stderr: {}",
exit_code,
stderr
))]
ReportResult { exit_code: i32, stderr: String },

#[snafu(display("Report type must be specified"))]
ReportTypeMissing {},

#[snafu(display("Report type '{}' is not supported", report_type))]
ReportNotSupported { report_type: String },

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

// Update related errors
Expand Down
55 changes: 54 additions & 1 deletion sources/api/apiserver/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use error::Result;
use fs2::FileExt;
use http::StatusCode;
use log::info;
use model::{ConfigurationFiles, Model, Services, Settings};
use model::{ConfigurationFiles, Model, Report, Services, Settings};
use nix::unistd::{chown, Gid};
use snafu::{ensure, OptionExt, ResultExt};
use std::collections::{HashMap, HashSet};
Expand All @@ -27,6 +27,7 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync;
use thar_be_updates::status::{UpdateStatus, UPDATE_LOCKFILE};
use tokio::process::Command as AsyncCommand;

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

Expand Down Expand Up @@ -121,6 +122,11 @@ where
)
.service(web::scope("/updates").route("/status", web::get().to(get_update_status)))
.service(web::resource("/exec").route(web::get().to(exec::ws_exec)))
.service(
web::scope("/report")
.route("", web::get().to(list_reports))
.route("/cis", web::get().to(get_cis_report)),
)
})
.workers(threads)
.bind_uds(socket_path.as_ref())
Expand Down Expand Up @@ -543,6 +549,46 @@ async fn reboot() -> Result<HttpResponse> {
Ok(HttpResponse::NoContent().finish())
}

/// Gets the set of report types supported by this host.
async fn list_reports() -> Result<ReportListResponse> {
// Add each report to list response when adding a new handler
let data = vec![Report {
name: "cis".to_string(),
description: "CIS Bottlerocket Benchmark".to_string(),
}];
Ok(ReportListResponse(data))
}

/// Gets the Bottlerocket CIS benchmark report.
async fn get_cis_report(query: web::Query<HashMap<String, String>>) -> Result<HttpResponse> {
let mut cmd = AsyncCommand::new("/usr/bin/bloodhound");

// Check for requested level, default is 1
if let Some(level) = query.get("level") {
cmd.arg("-l").arg(level);
}

// Check for requested format, default is text
if let Some(format) = query.get("format") {
cmd.arg("-f").arg(format);
}

let output = cmd.output().await.context(error::ReportExecSnafu)?;
ensure!(
output.status.success(),
error::ReportResultSnafu {
exit_code: match output.status.code() {
Some(code) => code,
None => output.status.signal().unwrap_or(1),
},
stderr: String::from_utf8_lossy(&output.stderr),
}
);
Ok(HttpResponse::Ok()
.content_type("application/text")
.body(String::from_utf8_lossy(&output.stdout).to_string()))
}

// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=

// Helpers for handler methods called by the router
Expand Down Expand Up @@ -572,6 +618,7 @@ impl ResponseError for error::Error {
MissingInput { .. } => StatusCode::BAD_REQUEST,
EmptyInput { .. } => StatusCode::BAD_REQUEST,
NewKey { .. } => StatusCode::BAD_REQUEST,
ReportTypeMissing { .. } => StatusCode::BAD_REQUEST,

// 404 Not Found
MissingData { .. } => StatusCode::NOT_FOUND,
Expand All @@ -582,6 +629,7 @@ impl ResponseError for error::Error {

// 422 Unprocessable Entity
CommitWithNoPending => StatusCode::UNPROCESSABLE_ENTITY,
ReportNotSupported { .. } => StatusCode::UNPROCESSABLE_ENTITY,

// 423 Locked
UpdateShareLock { .. } => StatusCode::LOCKED,
Expand Down Expand Up @@ -618,6 +666,8 @@ impl ResponseError for error::Error {
UpdateStatusParse { .. } => StatusCode::INTERNAL_SERVER_ERROR,
UpdateInfoParse { .. } => StatusCode::INTERNAL_SERVER_ERROR,
UpdateLockOpen { .. } => StatusCode::INTERNAL_SERVER_ERROR,
ReportExec { .. } => StatusCode::INTERNAL_SERVER_ERROR,
ReportResult { .. } => StatusCode::INTERNAL_SERVER_ERROR,
};

HttpResponse::build(status_code).body(self.to_string())
Expand Down Expand Up @@ -702,3 +752,6 @@ impl_responder_for!(ChangedKeysResponse, self, self.0);

struct TransactionListResponse(HashSet<String>);
impl_responder_for!(TransactionListResponse, self, self.0);

struct ReportListResponse(Vec<Report>);
impl_responder_for!(ReportListResponse, self, self.0);
48 changes: 48 additions & 0 deletions sources/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,51 @@ paths:
description: "Connection upgraded to WebSocket"
500:
description: "Server error"

/report:
get:
summary: "Get available report types"
operationId: "get_reports"
responses:
200:
description: "Successful request"
content:
application/json:
schema:
type: array
$ref: "Report"
500:
description: "Server error"

/report/cis:
get:
summary: "Get CIS Bottlerocket benchmark report"
operationId: "cis-report"
parameters:
- in: query
name: level
description: "The CIS compliance level to test (1 or 2). Default level is 1."
schema:
type: integer
minimum: 1
maximum: 2
required: false
- in: query
name: format
description: "The CIS compliance report format (text or json). Default format is text."
schema:
type: string
required: false
responses:
200:
description: "Successful request"
content:
application/json:
schema:
$ref: "String"
400:
description: "Bad request input"
422:
description: "Unprocessable request"
500:
description: "Server error"
6 changes: 6 additions & 0 deletions sources/models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,3 +519,9 @@ struct OciDefaultsResourceLimit {
hard_limit: u32,
soft_limit: u32,
}

#[model(add_option = false)]
struct Report {
name: String,
description: String,
}

0 comments on commit 867d912

Please sign in to comment.