feat(fixtures): curated demo targets + seed script + golden PLC baselines (#187)
CI / Check (push) Skipped
CI / Check (pull_request) Failing after 3m1s
CI / Detect Changes (pull_request) Skipped
CI / Deploy Agent (pull_request) Skipped
CI / Deploy Dashboard (pull_request) Skipped
CI / Deploy Docs (pull_request) Skipped
CI / Deploy MCP (pull_request) Skipped
CI / Check (push) Skipped
CI / Check (pull_request) Failing after 3m1s
CI / Detect Changes (pull_request) Skipped
CI / Deploy Agent (pull_request) Skipped
CI / Deploy Dashboard (pull_request) Skipped
CI / Deploy Docs (pull_request) Skipped
CI / Deploy MCP (pull_request) Skipped
Versioned, reproducible set of representative targets so every scan path can be exercised repeatably and the nightly regression (#188) has a baseline. - fixtures/demo-targets/targets.json: 5 targets — PlcSps composite (pump_station.st + pump_fbd.xml + optional Modbus live URL + optional firmware image), PlcSps pure (conveyor.xml + traffic_light.st), plain git SAST (sharang/cra-vuln-demo, pinned), WebApp (juice-shop v19.2.1 + live URL), FirmwareRtos (zephyr example-application, pinned). Each carries an `expect` golden baseline (min_findings, sast_rule_ids, cwes, control_refs, min_sbom_components, scans_offered, pentest_supported, detected_facts). - compliance-agent::fixtures: typed loader (DemoTargets/DemoTarget/ DemoArtifact/Expect) + lib tests that keep the manifest well-formed and assert the PLC baselines offline by running analyze_tree over the checked-in fixtures (runs in the normal --lib CI job). - scripts/seed-demo-targets.sh: curl+jq seeder over the public onboarding API (create → upload → detect → optional --scan), --only, --reset (deletes only the "Demo · " prefix), env overrides for infra-dependent artifacts. - docs/guide/demo-targets.md + sidebar entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgxGHn22YEfQz5fLHSHkLv
This commit is contained in:
co-authored by
Claude Fable 5
parent
0834547a74
commit
3ac4b34c29
@@ -0,0 +1,306 @@
|
||||
//! Curated demo targets (#187).
|
||||
//!
|
||||
//! `fixtures/demo-targets/targets.json` is the versioned, reproducible set of
|
||||
//! representative targets every scan path can be exercised against: PlcSps
|
||||
//! (composite), a plain git SAST repo, a WebApp (git + live URL) and an RTOS
|
||||
//! firmware repo. The manifest is consumed by:
|
||||
//!
|
||||
//! * `scripts/seed-demo-targets.sh` — onboards the targets through the
|
||||
//! public API (optionally triggering a first scan),
|
||||
//! * the nightly regression (#188) — the `expect` block is the golden
|
||||
//! baseline per target,
|
||||
//! * the lib tests below — which keep the manifest well-formed and assert the
|
||||
//! PLC baselines offline (no Mongo, no network) on every CI run.
|
||||
//!
|
||||
//! Artifacts come in two flavours: `source_ref` (git URL / live URL / image
|
||||
//! ref; may be overridden by the env var named in `source_ref_env`) and
|
||||
//! `upload` (a file path relative to the workspace root, pushed through
|
||||
//! `POST /targets/{id}/artifacts/upload`; may instead come from the env var
|
||||
//! named in `upload_env`). Artifacts flagged `optional` are skipped when their
|
||||
//! env var is unset, so the set seeds cleanly on a laptop without OT infra.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use compliance_core::models::onboarding::{ArtifactKind, PlcFormat, TargetType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Manifest path, relative to the workspace root.
|
||||
pub const MANIFEST_PATH: &str = "fixtures/demo-targets/targets.json";
|
||||
|
||||
/// Why a manifest could not be loaded.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FixtureError {
|
||||
#[error("read {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("parse {path}: {source}")]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// The whole demo-target set.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DemoTargets {
|
||||
/// Bumped on incompatible manifest changes.
|
||||
pub schema_version: u32,
|
||||
/// Prepended to every target name on seed; the seed script's `--reset`
|
||||
/// deletes exactly the targets carrying this prefix.
|
||||
pub name_prefix: String,
|
||||
pub targets: Vec<DemoTarget>,
|
||||
}
|
||||
|
||||
/// One curated target.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DemoTarget {
|
||||
/// Stable machine key (used in baseline reports and as the default
|
||||
/// `repo_id`-ish handle in nightly output).
|
||||
pub key: String,
|
||||
/// Human name (without the prefix).
|
||||
pub name: String,
|
||||
pub target_type: TargetType,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artifacts: Vec<DemoArtifact>,
|
||||
/// Golden baseline for the nightly regression.
|
||||
#[serde(default)]
|
||||
pub expect: Expect,
|
||||
}
|
||||
|
||||
/// One artifact of a curated target.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DemoArtifact {
|
||||
pub kind: ArtifactKind,
|
||||
/// Literal reference (git URL, live URL, image ref) — the default when
|
||||
/// `source_ref_env` is unset in the environment.
|
||||
#[serde(default)]
|
||||
pub source_ref: Option<String>,
|
||||
/// Env var that overrides `source_ref` at seed time.
|
||||
#[serde(default)]
|
||||
pub source_ref_env: Option<String>,
|
||||
/// Workspace-relative file to upload as this artifact's content.
|
||||
#[serde(default)]
|
||||
pub upload: Option<String>,
|
||||
/// Env var holding an absolute path to upload instead of `upload`.
|
||||
#[serde(default)]
|
||||
pub upload_env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
/// Commit the baseline was recorded against (informational: the agent
|
||||
/// clones `branch`; re-pin when the baseline moves).
|
||||
#[serde(default)]
|
||||
pub pin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pin_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub plc_format: Option<PlcFormat>,
|
||||
/// Skip silently when the env var is unset (needs infra not every
|
||||
/// environment has).
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
impl DemoArtifact {
|
||||
/// The upload path, resolved against the workspace root. `None` for
|
||||
/// reference-style artifacts or env-only uploads whose var is unset.
|
||||
pub fn upload_path(&self, root: &Path) -> Option<PathBuf> {
|
||||
if let Some(var) = &self.upload_env {
|
||||
if let Ok(p) = std::env::var(var) {
|
||||
if !p.is_empty() {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.upload.as_ref().map(|p| root.join(p))
|
||||
}
|
||||
|
||||
/// True when this artifact only exists if its env var is provided.
|
||||
pub fn env_only(&self) -> bool {
|
||||
self.upload.is_none() && self.source_ref.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Golden baseline; every field is optional so a target can assert only what
|
||||
/// is deterministic for it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Expect {
|
||||
/// Lower bound on total findings after a full scan.
|
||||
#[serde(default)]
|
||||
pub min_findings: Option<usize>,
|
||||
/// Rule ids that must be present among SAST findings.
|
||||
#[serde(default)]
|
||||
pub sast_rule_ids: Vec<String>,
|
||||
/// CWE ids (`CWE-NNN`) that must be present.
|
||||
#[serde(default)]
|
||||
pub cwes: Vec<String>,
|
||||
/// Control refs (e.g. `cra-ai-8`) that must be stamped on some finding.
|
||||
#[serde(default)]
|
||||
pub control_refs: Vec<String>,
|
||||
/// Lower bound on SBOM components.
|
||||
#[serde(default)]
|
||||
pub min_sbom_components: Option<usize>,
|
||||
/// Scans `applicable-scans` must offer for this target.
|
||||
#[serde(default)]
|
||||
pub scans_offered: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub pentest_supported: Option<bool>,
|
||||
/// `key -> value` facts `detect` must surface.
|
||||
#[serde(default)]
|
||||
pub detected_facts: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DemoTargets {
|
||||
/// Workspace root, derived from this crate's manifest dir.
|
||||
pub fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Load the checked-in manifest.
|
||||
pub fn load() -> Result<Self, FixtureError> {
|
||||
Self::load_from(&Self::workspace_root().join(MANIFEST_PATH))
|
||||
}
|
||||
|
||||
/// Load a manifest from an explicit path.
|
||||
pub fn load_from(path: &Path) -> Result<Self, FixtureError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(|source| FixtureError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
serde_json::from_str(&raw).map_err(|source| FixtureError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Display name as the seed script creates it.
|
||||
pub fn full_name(&self, t: &DemoTarget) -> String {
|
||||
format!("{}{}", self.name_prefix, t.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pipeline::plc::analyze_tree;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
fn manifest() -> DemoTargets {
|
||||
DemoTargets::load().expect("demo manifest loads")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_is_well_formed() {
|
||||
let m = manifest();
|
||||
let root = DemoTargets::workspace_root();
|
||||
assert_eq!(m.schema_version, 1);
|
||||
assert!(!m.targets.is_empty());
|
||||
|
||||
let mut keys = HashSet::new();
|
||||
for t in &m.targets {
|
||||
assert!(keys.insert(t.key.as_str()), "duplicate key {}", t.key);
|
||||
assert!(!t.artifacts.is_empty(), "{}: needs artifacts", t.key);
|
||||
for a in &t.artifacts {
|
||||
let refs = usize::from(a.source_ref.is_some()) + usize::from(a.upload.is_some());
|
||||
let env_only = a.env_only();
|
||||
assert!(
|
||||
refs == 1 || (env_only && a.optional),
|
||||
"{}: artifact {:?} must have exactly one of source_ref/upload, \
|
||||
or be optional + env-only",
|
||||
t.key,
|
||||
a.kind
|
||||
);
|
||||
if let Some(p) = &a.upload {
|
||||
assert!(root.join(p).is_file(), "{}: upload {p} missing", t.key);
|
||||
}
|
||||
match a.kind {
|
||||
ArtifactKind::PlcProject => {
|
||||
assert!(
|
||||
a.plc_format.is_some(),
|
||||
"{}: PLC upload needs plc_format",
|
||||
t.key
|
||||
);
|
||||
}
|
||||
ArtifactKind::GitRepo => {
|
||||
assert!(a.branch.is_some(), "{}: git artifact needs branch", t.key);
|
||||
assert!(a.pin.is_some(), "{}: git artifact needs a pin", t.key);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage the story asks for: PlcSps, firmware, web app, plain git.
|
||||
let types: HashSet<TargetType> = m.targets.iter().map(|t| t.target_type).collect();
|
||||
for want in [
|
||||
TargetType::PlcSps,
|
||||
TargetType::FirmwareRtos,
|
||||
TargetType::WebApp,
|
||||
TargetType::BackendService,
|
||||
] {
|
||||
assert!(types.contains(&want), "manifest lacks a {want:?} target");
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline golden baseline: the checked-in PLC fixtures must keep producing
|
||||
/// the rule ids the manifest promises. Runs the real control-logic
|
||||
/// analyzer over the fixture files.
|
||||
#[test]
|
||||
fn plc_fixtures_meet_golden_baseline() {
|
||||
let m = manifest();
|
||||
let root = DemoTargets::workspace_root();
|
||||
let all = analyze_tree(&root.join("examples/plc-demo"), "demo");
|
||||
|
||||
for t in m
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|t| t.target_type == TargetType::PlcSps)
|
||||
{
|
||||
let files: Vec<String> = t
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|a| a.kind == ArtifactKind::PlcProject)
|
||||
.filter_map(|a| a.upload.as_deref())
|
||||
.filter_map(|p| Path::new(p).file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(!files.is_empty(), "{}: no PLC uploads", t.key);
|
||||
|
||||
let mine: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.file_path
|
||||
.as_deref()
|
||||
.is_some_and(|p| files.iter().any(|n| p.ends_with(n.as_str())))
|
||||
})
|
||||
.collect();
|
||||
let rules: BTreeSet<&str> = mine.iter().filter_map(|f| f.rule_id.as_deref()).collect();
|
||||
eprintln!("{} -> {} findings, rules {:?}", t.key, mine.len(), rules);
|
||||
|
||||
if let Some(min) = t.expect.min_findings {
|
||||
assert!(
|
||||
mine.len() >= min,
|
||||
"{}: {} findings < min {min}",
|
||||
t.key,
|
||||
mine.len()
|
||||
);
|
||||
}
|
||||
for r in &t.expect.sast_rule_ids {
|
||||
assert!(
|
||||
rules.contains(r.as_str()),
|
||||
"{}: missing rule {r}; got {rules:?}",
|
||||
t.key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod config;
|
||||
pub mod controls;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod fixtures;
|
||||
pub mod ingest;
|
||||
pub mod llm;
|
||||
pub mod pentest;
|
||||
|
||||
Reference in New Issue
Block a user