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

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:
Sharang Parnerkar
2026-08-31 14:33:01 +02:00
co-authored by Claude Fable 5
parent 0834547a74
commit 3ac4b34c29
6 changed files with 732 additions and 0 deletions
+306
View File
@@ -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
);
}
}
}
}
+1
View File
@@ -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;