diff --git a/compliance-agent/src/fixtures.rs b/compliance-agent/src/fixtures.rs new file mode 100644 index 0000000..0d74900 --- /dev/null +++ b/compliance-agent/src/fixtures.rs @@ -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, +} + +/// 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, + #[serde(default)] + pub artifacts: Vec, + /// 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, + /// Env var that overrides `source_ref` at seed time. + #[serde(default)] + pub source_ref_env: Option, + /// Workspace-relative file to upload as this artifact's content. + #[serde(default)] + pub upload: Option, + /// Env var holding an absolute path to upload instead of `upload`. + #[serde(default)] + pub upload_env: Option, + #[serde(default)] + pub branch: Option, + /// Commit the baseline was recorded against (informational: the agent + /// clones `branch`; re-pin when the baseline moves). + #[serde(default)] + pub pin: Option, + #[serde(default)] + pub pin_tag: Option, + #[serde(default)] + pub plc_format: Option, + /// 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 { + 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, + /// Rule ids that must be present among SAST findings. + #[serde(default)] + pub sast_rule_ids: Vec, + /// CWE ids (`CWE-NNN`) that must be present. + #[serde(default)] + pub cwes: Vec, + /// Control refs (e.g. `cra-ai-8`) that must be stamped on some finding. + #[serde(default)] + pub control_refs: Vec, + /// Lower bound on SBOM components. + #[serde(default)] + pub min_sbom_components: Option, + /// Scans `applicable-scans` must offer for this target. + #[serde(default)] + pub scans_offered: Vec, + #[serde(default)] + pub pentest_supported: Option, + /// `key -> value` facts `detect` must surface. + #[serde(default)] + pub detected_facts: BTreeMap, +} + +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::load_from(&Self::workspace_root().join(MANIFEST_PATH)) + } + + /// Load a manifest from an explicit path. + pub fn load_from(path: &Path) -> Result { + 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 = 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 = 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 + ); + } + } + } +} diff --git a/compliance-agent/src/lib.rs b/compliance-agent/src/lib.rs index 00b0691..275440b 100644 --- a/compliance-agent/src/lib.rs +++ b/compliance-agent/src/lib.rs @@ -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; diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4de78a8..cd0486c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -21,6 +21,7 @@ export default withMermaid(defineConfig({ { text: 'Adding Repositories', link: '/guide/repositories' }, { text: 'Running Scans', link: '/guide/scanning' }, { text: 'PLC / SPS (CODESYS)', link: '/guide/plc' }, + { text: 'Demo Targets', link: '/guide/demo-targets' }, { text: 'Understanding Findings', link: '/guide/findings' }, { text: 'SBOM & Licenses', link: '/guide/sbom' }, { text: 'Issues & Tracking', link: '/guide/issues' }, diff --git a/docs/guide/demo-targets.md b/docs/guide/demo-targets.md new file mode 100644 index 0000000..f2df84b --- /dev/null +++ b/docs/guide/demo-targets.md @@ -0,0 +1,84 @@ +# Demo Targets + +Certifai ships a small, versioned set of **demo targets** — representative +inputs that exercise every scan path repeatably. They double as the fixture +set for the nightly regression and as a ready-made walkthrough for demos. + +The set lives in [`fixtures/demo-targets/targets.json`](https://git.breakpilot.com/sharang/compliance-scanner-agent/src/branch/main/fixtures/demo-targets/targets.json). + +## What is in the set + +| Key | Target type | Artifacts | What it exercises | +|-----|-------------|-----------|-------------------| +| `plc-pump-station` | PLC / SPS (composite) | `pump_station.st`, `pump_fbd.xml`, optional Modbus live URL, optional firmware image | PLC control-logic SAST (ST **and** FBD-as-XML), ICS probe, semantic CRA/master-control mapping | +| `plc-conveyor-line` | PLC / SPS | `conveyor.xml`, `traffic_light.st` | Pure control-logic SAST on a PLCopen-XML program plus a realistic OpenPLC-style sample with three planted defects | +| `git-cra-vuln-demo` | Backend service | git `sharang/cra-vuln-demo` | Plain git SAST: Semgrep → CWE → CRA control refs (`cra-ai-8/13/20`) | +| `web-juice-shop` | Web app | git `juice-shop/juice-shop` @ v19.2.1 + live URL | SAST + SBOM/CVE on a large Node app, DAST + pentest against the running instance | +| `firmware-zephyr-example` | Firmware (RTOS) | git `zephyrproject-rtos/example-application` | Tramiton detect handoff (`build_system = zephyr`), firmware source SBOM | + +The PLC files are the same ones under `examples/plc-demo/` that the PLC rule +tests already run against, so their expected rule ids are enforced offline on +every CI run (`compliance-agent::fixtures` tests). + +## Reproducibility + +* Git artifacts carry a `pin` (commit SHA, plus `pin_tag` where a release tag + exists). The agent clones `branch`; the pin records **which commit the + baseline was recorded against**. When a baseline drifts, re-pin and update + `expect` in the same change. +* Uploaded artifacts are checked into this repository. +* Anything that needs infrastructure is **optional** and env-driven, so the + set seeds cleanly on a laptop and gains the dynamic pieces on `comp-dev`: + +| Env var | Used by | Meaning | +|---------|---------|---------| +| `DEMO_WEB_URL` | `web-juice-shop` | Live URL for DAST/pentest. Default `http://localhost:3000` — run `docker run -d -p 3000:3000 bkimminich/juice-shop:v19.2.1`. | +| `DEMO_PLC_MODBUS_URL` | `plc-pump-station` | Modbus endpoint for the ICS probe (in-cluster default `modbus://plc-sim:502`). Skipped when unset. | +| `DEMO_PLC_FIRMWARE_IMAGE` | `plc-pump-station` | Absolute path to a device firmware image to attach. Skipped when unset. | + +## Seeding the targets + +```bash +# against a local dev agent (no Keycloak → dev tenant) +scripts/seed-demo-targets.sh + +# seed and trigger the first scan of each +scripts/seed-demo-targets.sh --scan + +# only some targets +scripts/seed-demo-targets.sh --only web-juice-shop,git-cra-vuln-demo + +# wipe every "Demo · " target and reseed +scripts/seed-demo-targets.sh --reset --scan + +# a deployed agent +AGENT_URL=https://comp-dev.breakpilot.com AGENT_TOKEN=$TOKEN \ +DEMO_PLC_MODBUS_URL=modbus://plc-sim:502 scripts/seed-demo-targets.sh --scan +``` + +The script uses only `curl` + `jq` and the public onboarding API: +`POST /api/v1/targets`, `POST /api/v1/targets/{id}/artifacts/upload`, +`POST /api/v1/targets/{id}/detect`, `POST /api/v1/targets/{id}/scan`. +Every seeded target is named `Demo · `; `--reset` deletes exactly that +prefix and nothing else. + +## Manual (re)onboarding + +Each target can also be created through the onboarding wizard: + +1. **PLC targets** — pick *PLC / SPS*, upload the `.st` / `.xml` files from + `examples/plc-demo/`, optionally add a `modbus://` live URL. See + [PLC / SPS (CODESYS)](/guide/plc). +2. **Git targets** — pick the type, add the git URL and branch from the + manifest. Public repos need no credentials. +3. **Web app** — add the git repo *and* the live URL; enable DAST and, if + wanted, pentest on the scan-selection step. + +## Golden baselines + +Each target's `expect` block states what a healthy scan must produce +(`min_findings`, required `sast_rule_ids`, `cwes`, `control_refs`, +`min_sbom_components`, `scans_offered`, `pentest_supported`, +`detected_facts`). The PLC baselines are asserted in unit tests today; the +nightly regression story (#188) runs the full set against a live agent and +alerts on drift. diff --git a/fixtures/demo-targets/targets.json b/fixtures/demo-targets/targets.json new file mode 100644 index 0000000..15ccb1c --- /dev/null +++ b/fixtures/demo-targets/targets.json @@ -0,0 +1,161 @@ +{ + "schema_version": 1, + "name_prefix": "Demo · ", + "targets": [ + { + "key": "plc-pump-station", + "name": "PLC pump station (ST + FBD, composite)", + "target_type": "plc_sps", + "description": "Composite PlcSps demo: Structured Text + FBD-as-PLCopen-XML control logic, plus an optional live Modbus endpoint (in-cluster plc-sim) and an optional device firmware image. Exercises PLC SAST, ICS probe, semantic control mapping.", + "artifacts": [ + { + "kind": "plc_project", + "upload": "examples/plc-demo/pump_station.st", + "plc_format": "structured_text" + }, + { + "kind": "plc_project", + "upload": "examples/plc-demo/pump_fbd.xml", + "plc_format": "plcopen_xml" + }, + { + "kind": "live_url", + "source_ref_env": "DEMO_PLC_MODBUS_URL", + "source_ref": "modbus://plc-sim:502", + "optional": true + }, + { + "kind": "firmware_image", + "upload_env": "DEMO_PLC_FIRMWARE_IMAGE", + "optional": true + } + ], + "expect": { + "min_findings": 16, + "sast_rule_ids": [ + "plc-hardcoded-credential", + "plc-default-password", + "plc-safety-bypass", + "plc-array-unchecked-index", + "plc-insecure-comm", + "plc-insecure-protocol-port", + "plc-unstructured-jump", + "plc-division-by-zero" + ], + "cwes": [ + "CWE-798", + "CWE-319", + "CWE-1384" + ], + "control_refs_any": true + } + }, + { + "key": "plc-conveyor-line", + "name": "PLC conveyor + traffic light (PLCopen XML + ST)", + "target_type": "plc_sps", + "description": "Pure PLC SAST demo: a PLCopen-XML conveyor program and a realistic OpenPLC-style traffic-light program with three planted defects. Exercises the control-logic rules without any dynamic infra.", + "artifacts": [ + { + "kind": "plc_project", + "upload": "examples/plc-demo/conveyor.xml", + "plc_format": "plcopen_xml" + }, + { + "kind": "plc_project", + "upload": "examples/plc-demo/traffic_light.st", + "plc_format": "structured_text" + } + ], + "expect": { + "min_findings": 8, + "sast_rule_ids": [ + "plc-hardcoded-credential", + "plc-default-password", + "plc-safety-bypass", + "plc-insecure-comm", + "plc-insecure-protocol-port" + ] + } + }, + { + "key": "git-cra-vuln-demo", + "name": "Git SAST · cra-vuln-demo", + "target_type": "backend_service", + "description": "Plain git SAST target. Small deliberately-vulnerable Python service (hardcoded credential, weak cipher, SQL injection, cleartext transport) used to prove the CWE → CRA control mapping.", + "artifacts": [ + { + "kind": "git_repo", + "source_ref": "https://git.breakpilot.com/sharang/cra-vuln-demo.git", + "branch": "main", + "pin": "21951249b9977d0c7feb555a9d5ce42c70ab5ae4" + } + ], + "expect": { + "min_findings": 3, + "cwes": [ + "CWE-798", + "CWE-327", + "CWE-89" + ], + "control_refs": [ + "cra-ai-8", + "cra-ai-13", + "cra-ai-20" + ] + } + }, + { + "key": "web-juice-shop", + "name": "Web app · OWASP Juice Shop", + "target_type": "web_app", + "description": "WebApp target: git repo for SAST/SBOM/CVE plus a live URL for DAST + pentest. Run the instance locally with `docker run -d -p 3000:3000 bkimminich/juice-shop:v19.2.1` or point DEMO_WEB_URL at a deployed copy.", + "artifacts": [ + { + "kind": "git_repo", + "source_ref": "https://github.com/juice-shop/juice-shop.git", + "branch": "master", + "pin": "f87c6f58c49b61c9de20e4d69a9bdb1fbd4f3bd3", + "pin_tag": "v19.2.1" + }, + { + "kind": "live_url", + "source_ref_env": "DEMO_WEB_URL", + "source_ref": "http://localhost:3000" + } + ], + "expect": { + "min_findings": 10, + "min_sbom_components": 500, + "scans_offered": [ + "sast", + "dast" + ], + "pentest_supported": true + } + }, + { + "key": "firmware-zephyr-example", + "name": "Firmware RTOS · Zephyr example-application", + "target_type": "firmware_rtos", + "description": "Upstream Zephyr example application (Apache-2.0). Exercises the tramiton detect handoff (build system = zephyr) and the firmware source SBOM path.", + "artifacts": [ + { + "kind": "git_repo", + "source_ref": "https://github.com/zephyrproject-rtos/example-application.git", + "branch": "main", + "pin": "38a6d9b276ed434454900130cacc87d058d3ac62" + } + ], + "expect": { + "detected_facts": { + "build_system": "zephyr" + }, + "scans_offered": [ + "sast" + ], + "pentest_supported": false + } + } + ] +} diff --git a/scripts/seed-demo-targets.sh b/scripts/seed-demo-targets.sh new file mode 100755 index 0000000..c35ea01 --- /dev/null +++ b/scripts/seed-demo-targets.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Seed the curated demo targets (#187) into a running compliance-agent. +# +# Reads fixtures/demo-targets/targets.json and, for every target: +# 1. POST /api/v1/targets (name = name_prefix + name) +# 2. POST /api/v1/targets/{id}/artifacts/upload for each `upload` artifact +# 3. POST /api/v1/targets/{id}/detect (surface detected facts) +# 4. POST /api/v1/targets/{id}/scan (only with --scan) +# +# Artifact env overrides (see the manifest): DEMO_WEB_URL, DEMO_PLC_MODBUS_URL, +# DEMO_PLC_FIRMWARE_IMAGE. Optional artifacts whose env var is unset are +# skipped, so the set seeds on a bare laptop; set them on comp-dev / Orca. +# +# Usage: +# scripts/seed-demo-targets.sh # seed all +# scripts/seed-demo-targets.sh --scan # seed + trigger first scan +# scripts/seed-demo-targets.sh --only web-juice-shop,git-cra-vuln-demo +# scripts/seed-demo-targets.sh --reset # delete every "Demo · " target +# scripts/seed-demo-targets.sh --reset --scan # reset, reseed, scan +# +# Env: +# AGENT_URL base URL of the agent (default http://localhost:3011) +# AGENT_TOKEN bearer token; omit for dev mode (no Keycloak → dev tenant) +# MANIFEST alternative manifest path + +set -euo pipefail + +AGENT_URL="${AGENT_URL:-http://localhost:3011}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="${MANIFEST:-$ROOT/fixtures/demo-targets/targets.json}" + +DO_SCAN=0 +DO_RESET=0 +ONLY="" +while [[ $# -gt 0 ]]; do + case "$1" in + --scan) DO_SCAN=1 ;; + --reset) DO_RESET=1 ;; + --only) ONLY="$2"; shift ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +for bin in jq curl; do + command -v "$bin" >/dev/null || { echo "need $bin" >&2; exit 1; } +done +[[ -f "$MANIFEST" ]] || { echo "manifest not found: $MANIFEST" >&2; exit 1; } + +AUTH=() +[[ -n "${AGENT_TOKEN:-}" ]] && AUTH=(-H "Authorization: Bearer ${AGENT_TOKEN}") + +green() { printf '\033[32m%s\033[0m' "$*"; } +yellow() { printf '\033[33m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } + +# api METHOD PATH [curl args...] → body on stdout; non-2xx → exit 1 with body. +api() { + local method="$1" path="$2"; shift 2 + local out code + out=$(curl -sS -X "$method" "${AGENT_URL}${path}" "${AUTH[@]}" -w '\n%{http_code}' "$@") + code="${out##*$'\n'}" + out="${out%$'\n'*}" + if [[ "$code" != 2* ]]; then + echo "$(red "HTTP $code") $method $path" >&2 + echo "$out" >&2 + return 1 + fi + printf '%s' "$out" +} + +PREFIX="$(jq -r '.name_prefix' "$MANIFEST")" + +reset_targets() { + echo "== reset: deleting targets named '${PREFIX}*'" + local list ids + list=$(api GET "/api/v1/targets?limit=500") + ids=$(jq -r --arg p "$PREFIX" '.data[] | select(.name | startswith($p)) | ._id."$oid"' <<<"$list") + local n=0 + for id in $ids; do + api DELETE "/api/v1/targets/${id}" >/dev/null && n=$((n + 1)) + done + echo " deleted $n" +} + +# Build the JSON artifact list for reference-style (non-upload) artifacts. +# Prints one JSON object per artifact that should be created inline. +inline_artifacts() { + local target_json="$1" + jq -c '.artifacts[] | select(.upload == null and .upload_env == null)' <<<"$target_json" | + while IFS= read -r a; do + local kind env ref optional branch + kind=$(jq -r '.kind' <<<"$a") + env=$(jq -r '.source_ref_env // empty' <<<"$a") + ref=$(jq -r '.source_ref // empty' <<<"$a") + optional=$(jq -r '.optional // false' <<<"$a") + branch=$(jq -r '.branch // empty' <<<"$a") + if [[ -n "$env" && -n "${!env:-}" ]]; then + ref="${!env}" + elif [[ -n "$env" && "$optional" == "true" ]]; then + echo " $(yellow skip) $kind (set \$$env to include)" >&2 + continue + fi + [[ -n "$ref" ]] || continue + jq -cn --arg k "$kind" --arg r "$ref" --arg b "$branch" \ + '{kind:$k, source_ref:$r} + (if $b != "" then {branch:$b} else {} end)' + done +} + +# Upload every `upload` / `upload_env` artifact of the target. +upload_artifacts() { + local id="$1" target_json="$2" + jq -c '.artifacts[] | select(.upload != null or .upload_env != null)' <<<"$target_json" | + while IFS= read -r a; do + local kind path env fmt optional + kind=$(jq -r '.kind' <<<"$a") + env=$(jq -r '.upload_env // empty' <<<"$a") + path=$(jq -r '.upload // empty' <<<"$a") + fmt=$(jq -r '.plc_format // empty' <<<"$a") + optional=$(jq -r '.optional // false' <<<"$a") + if [[ -n "$env" && -n "${!env:-}" ]]; then + path="${!env}" + elif [[ -n "$path" ]]; then + path="$ROOT/$path" + elif [[ "$optional" == "true" ]]; then + echo " $(yellow skip) $kind (set \$$env to include)" >&2 + continue + fi + [[ -f "$path" ]] || { echo " $(red missing) $path" >&2; return 1; } + local form=(-F "file=@${path}" -F "kind=${kind}") + [[ -n "$fmt" ]] && form+=(-F "plc_format=${fmt}") + api POST "/api/v1/targets/${id}/artifacts/upload" "${form[@]}" >/dev/null + echo " $(green upload) $kind $(basename "$path")" + done +} + +seed_target() { + local t="$1" + local key name type desc + key=$(jq -r '.key' <<<"$t") + name="${PREFIX}$(jq -r '.name' <<<"$t")" + type=$(jq -r '.target_type' <<<"$t") + desc=$(jq -r '.description // ""' <<<"$t") + echo "== $key ($type)" + + local arts body resp id + arts=$(inline_artifacts "$t" | jq -cs '.') + body=$(jq -cn --arg n "$name" --arg tt "$type" --arg d "$desc" --argjson a "$arts" \ + '{name:$n, target_type:$tt, description:$d, artifacts:$a}') + resp=$(api POST "/api/v1/targets" -H 'Content-Type: application/json' -d "$body") + id=$(jq -r '.data._id."$oid"' <<<"$resp") + echo " $(green created) $id $(jq -r '.data.artifacts|length' <<<"$resp") inline artifact(s)" + + upload_artifacts "$id" "$t" + + local det + det=$(api POST "/api/v1/targets/${id}/detect" -H 'Content-Type: application/json' -d '{}' || true) + if [[ -n "$det" ]]; then + echo " detect → $(jq -r '.data.classification.suggested // "n/a"' <<<"$det")" + fi + + if [[ "$DO_SCAN" == 1 ]]; then + api POST "/api/v1/targets/${id}/scan" -H 'Content-Type: application/json' -d '{}' >/dev/null + echo " $(green scan) triggered" + fi +} + +[[ "$DO_RESET" == 1 ]] && reset_targets + +echo "== seeding from $MANIFEST → $AGENT_URL" +jq -c '.targets[]' "$MANIFEST" | while IFS= read -r t; do + key=$(jq -r '.key' <<<"$t") + if [[ -n "$ONLY" && ",$ONLY," != *",$key,"* ]]; then + continue + fi + seed_target "$t" +done +echo "== done"