//! The unified onboarding model. //! //! An [`OnboardedTarget`] is the single source of truth for anything the scanner //! can analyze. It records *what kind of software* the target is ([`TargetType`]), //! the concrete [`Artifact`]s that were provided for it (a git repo, a firmware //! image, a live URL, a PLC project, ...), the classifier's verdict, and the scan //! configuration. It replaces the older git-only `TrackedRepository` and the //! standalone `DastTarget`, both of which fold into this type as artifacts. use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::dast::{DastAuthConfig, DastTargetType}; use super::issue::TrackerType; use super::pentest::{Environment, PentestConfig, PentestStrategy}; use super::scan::ScanType; /// The family of software a target belongs to. /// /// Targets look endlessly varied but fall into a small enumerable set classified /// by where the analyzable signal lives. This drives the scan-applicability /// matrix and the onboarding wizard's type selection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TargetType { /// Browser-facing web application (front end + server). WebApp, /// Headless backend service / API (REST, GraphQL, gRPC). BackendService, /// Desktop application (Windows/macOS/Linux GUI or CLI binary). DesktopApp, /// Android application (APK / AAB). AndroidApp, /// iOS application (IPA). IosApp, /// Bare-metal embedded firmware (no operating system). FirmwareBareMetal, /// Embedded firmware running on an RTOS (Zephyr, FreeRTOS, ...). FirmwareRtos, /// Embedded Linux built with Yocto / OpenEmbedded (BSP + image). EmbeddedLinuxYocto, /// Programmable logic controller software (IEC 61131-3, PLCopen / SPS). PlcSps, } impl std::fmt::Display for TargetType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::WebApp => write!(f, "web_app"), Self::BackendService => write!(f, "backend_service"), Self::DesktopApp => write!(f, "desktop_app"), Self::AndroidApp => write!(f, "android_app"), Self::IosApp => write!(f, "ios_app"), Self::FirmwareBareMetal => write!(f, "firmware_bare_metal"), Self::FirmwareRtos => write!(f, "firmware_rtos"), Self::EmbeddedLinuxYocto => write!(f, "embedded_linux_yocto"), Self::PlcSps => write!(f, "plc_sps"), } } } /// The kind of artifact provided for a target. /// /// Which scans are possible is a function of the target type *and* which of /// these are present (SAST needs code, DAST needs a running URL, and so on). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ArtifactKind { /// A git repository (cloned for static analysis). GitRepo, /// A source archive (zip / tarball) with no live git remote. SourceArchive, /// A firmware image or binary blob. FirmwareImage, /// A mobile package: Android APK/AAB or iOS IPA. MobilePackage, /// An OCI/Docker container image reference. ContainerImage, /// A reachable running instance (base URL / endpoint) for dynamic testing. LiveUrl, /// A PLC project: PLCopen XML or Structured Text source. PlcProject, /// Free-form plaintext describing the target (feeds classification only). PlaintextDescription, } impl std::fmt::Display for ArtifactKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::GitRepo => write!(f, "git_repo"), Self::SourceArchive => write!(f, "source_archive"), Self::FirmwareImage => write!(f, "firmware_image"), Self::MobilePackage => write!(f, "mobile_package"), Self::ContainerImage => write!(f, "container_image"), Self::LiveUrl => write!(f, "live_url"), Self::PlcProject => write!(f, "plc_project"), Self::PlaintextDescription => write!(f, "plaintext_description"), } } } /// Credentials attached to an artifact. /// /// This folds both `TrackedRepository`'s git auth (`auth_token` / `auth_username` /// / SSH key) and `DastAuthConfig`'s HTTP auth (form / bearer / cookie) into one /// shape so a single artifact carries whatever it needs to be fetched or probed. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ArtifactAuth { /// Auth method: `none` | `token` | `basic` | `bearer` | `cookie` | `form` | `ssh`. #[serde(default)] pub method: String, /// Username (git user, basic-auth user, or `x-access-token` for PATs). pub username: Option, /// The secret credential: PAT, password, or bearer token. Encrypted at rest. pub secret: Option, /// Path to an SSH private key for git-over-SSH. pub ssh_key_path: Option, /// Login URL for form-based authentication. pub login_url: Option, /// Extra headers to send when authenticating / probing. pub headers: Option>, } impl From for ArtifactAuth { fn from(c: DastAuthConfig) -> Self { Self { method: c.method, username: c.username, // Prefer a bearer token; otherwise fall back to the password. secret: c.token.or(c.password), ssh_key_path: None, login_url: c.login_url, headers: c.headers, } } } /// Git-specific configuration for a [`ArtifactKind::GitRepo`] artifact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitArtifactConfig { /// Branch to scan. pub default_branch: String, /// Commit SHA of the last completed scan (change-detection watermark). pub last_scanned_commit: Option, /// Local clone path once the repo has been fetched. pub local_path: Option, } impl GitArtifactConfig { /// Config for a fresh git artifact on the given branch. pub fn on_branch(branch: impl Into) -> Self { Self { default_branch: branch.into(), last_scanned_commit: None, local_path: None, } } } impl Default for GitArtifactConfig { fn default() -> Self { Self::on_branch("main") } } /// Dynamic-analysis configuration for a [`ArtifactKind::LiveUrl`] artifact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebArtifactConfig { /// Whether the endpoint is a web app, REST API, or GraphQL API. pub target_kind: DastTargetType, /// URL paths to exclude from crawling / scanning. #[serde(default)] pub excluded_paths: Vec, /// Maximum crawl depth. pub max_crawl_depth: u32, /// Rate limit in requests per second. pub rate_limit: u32, /// Whether destructive methods (DELETE / PUT) are permitted. #[serde(default)] pub allow_destructive: bool, } impl Default for WebArtifactConfig { fn default() -> Self { Self { target_kind: DastTargetType::WebApp, excluded_paths: Vec::new(), max_crawl_depth: 3, rate_limit: 10, allow_destructive: false, } } } /// The source format of a PLC project artifact. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PlcFormat { /// PLCopen XML project export. PlcopenXml, /// IEC 61131-3 Structured Text source. StructuredText, /// A CODESYS project archive (`.projectarchive` — a zip bundling the project /// plus its referenced libraries and runtime; the source of the control-app SBOM). ProjectArchive, } /// PLC-specific configuration for a [`ArtifactKind::PlcProject`] artifact. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcArtifactConfig { /// The project source format. pub format: PlcFormat, } /// A single fact discovered about a target by ingest or classification /// (e.g. `language=rust`, `build_system=cmake`, `mcu=stm32f429`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DetectedFact { /// The fact name. pub key: String, /// The fact value. pub value: String, /// What produced the fact (e.g. `tramiton`, `language-fingerprint`). pub source: String, } impl DetectedFact { /// Build a fact from its parts. pub fn new( key: impl Into, value: impl Into, source: impl Into, ) -> Self { Self { key: key.into(), value: value.into(), source: source.into(), } } } /// One concrete thing provided for a target: code, a binary, a URL, etc. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Artifact { /// Stable per-artifact id (UUID v4) — scan steps reference this. pub id: String, /// What kind of artifact this is. pub kind: ArtifactKind, /// The source reference: git URL, blob id, live URL, or image ref. pub source_ref: String, /// Optional human-friendly label. pub display_name: Option, /// Content-addressed storage path once ingested (blobs only). pub stored_path: Option, /// SHA-256 of the ingested content (git artifacts store the head SHA). pub content_hash: Option, /// Size of the stored blob in bytes. pub size_bytes: Option, /// Credentials for fetching or probing this artifact. pub auth: Option, /// Git configuration (present for [`ArtifactKind::GitRepo`]). pub git: Option, /// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]). pub web: Option, /// PLC configuration (present for [`ArtifactKind::PlcProject`]). pub plc: Option, /// Facts discovered about this artifact by ingest / classification. #[serde(default)] pub detected: Vec, /// When this artifact was last ingested. #[serde(default, with = "super::serde_helpers::opt_bson_datetime")] pub ingested_at: Option>, } impl Artifact { /// A bare artifact of the given kind and source reference. fn bare(kind: ArtifactKind, source_ref: impl Into) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), kind, source_ref: source_ref.into(), display_name: None, stored_path: None, content_hash: None, size_bytes: None, auth: None, git: None, web: None, plc: None, detected: Vec::new(), ingested_at: None, } } /// A git-repository artifact tracking the given branch. pub fn git_repo(url: impl Into, branch: impl Into) -> Self { let mut a = Self::bare(ArtifactKind::GitRepo, url); a.git = Some(GitArtifactConfig::on_branch(branch)); a } /// A live-URL artifact with default crawl settings. pub fn live_url(url: impl Into) -> Self { let mut a = Self::bare(ArtifactKind::LiveUrl, url); a.web = Some(WebArtifactConfig::default()); a } /// A firmware-image artifact referenced by name (blob ingested later). pub fn firmware_image(source_ref: impl Into) -> Self { Self::bare(ArtifactKind::FirmwareImage, source_ref) } /// A source-archive artifact referenced by name (blob ingested later). pub fn source_archive(source_ref: impl Into) -> Self { Self::bare(ArtifactKind::SourceArchive, source_ref) } /// A mobile-package artifact (APK/AAB/IPA) referenced by name. pub fn mobile_package(source_ref: impl Into) -> Self { Self::bare(ArtifactKind::MobilePackage, source_ref) } /// A container-image artifact referenced by OCI ref. pub fn container_image(source_ref: impl Into) -> Self { Self::bare(ArtifactKind::ContainerImage, source_ref) } /// A PLC-project artifact in the given format. pub fn plc_project(source_ref: impl Into, format: PlcFormat) -> Self { let mut a = Self::bare(ArtifactKind::PlcProject, source_ref); a.plc = Some(PlcArtifactConfig { format }); a } /// A plaintext-description artifact (classification input only). pub fn plaintext(text: impl Into) -> Self { Self::bare(ArtifactKind::PlaintextDescription, text) } } /// One ranked candidate produced by the classifier. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TargetTypeCandidate { /// The candidate target type. pub target_type: TargetType, /// Confidence in `[0.0, 1.0]`. pub confidence: f32, /// Why this candidate was proposed. pub rationale: String, } /// The classifier's verdict for a target: a suggested type plus ranked /// alternatives and the facts the decision rested on. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Classification { /// The top-ranked target type. pub suggested: TargetType, /// All candidates, sorted by descending confidence. #[serde(default)] pub candidates: Vec, /// Facts gathered during classification. #[serde(default)] pub facts: Vec, /// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`). #[serde(default)] pub detected_by: Vec, /// When classification ran. #[serde(with = "super::serde_helpers::bson_datetime")] pub detected_at: DateTime, /// Whether a human confirmed the suggestion. #[serde(default)] pub confirmed: bool, } /// Issue-tracker linkage, migrated from `TrackedRepository`'s `tracker_*` fields. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IssueTrackerConfig { /// The tracker platform. pub tracker_type: Option, /// Tracker owner / organization. pub owner: Option, /// Tracker repository / project. pub repo: Option, /// Per-target tracker access token. pub token: Option, } /// How a target should be scanned. /// /// `enabled_scans` / `disabled_scans` override the scan-applicability matrix /// defaults; the pentest and tracker blocks reuse the existing wizard config. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TargetScanConfig { /// Scans explicitly turned on (empty means "use matrix defaults"). #[serde(default)] pub enabled_scans: Vec, /// Scans explicitly turned off. #[serde(default)] pub disabled_scans: Vec, /// Target environment (gates destructive / active testing). #[serde(default)] pub environment: Environment, /// Whether destructive tests are permitted for this target. #[serde(default)] pub allow_destructive: bool, /// Pentest strategy selector. pub strategy: Option, /// Full pentest wizard configuration. pub pentest: Option, /// Issue-tracker linkage. pub issue_tracker: Option, } /// A sibling product in the company suite that may already hold authoritative /// data for a target. compliance-scanner reconciles with these rather than /// recomputing what they already know. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExternalSystem { /// Reproducible-build & firmware compliance engine (build plan, SBOM, VEX, /// attestation). Tramiton, /// Code assistant (downstream remediation consumer). Werkpilot, /// Compliance-controls RAG (atomic controls derived from laws). BreakpilotCompliance, } impl std::fmt::Display for ExternalSystem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Tramiton => write!(f, "tramiton"), Self::Werkpilot => write!(f, "werkpilot"), Self::BreakpilotCompliance => write!(f, "breakpilot_compliance"), } } } /// A link from this target to a record in a sibling product, used to reconcile /// existing evidence instead of recomputing it. /// /// For tramiton, `project_id` is the shared cross-product key and /// `subject_sha256` matches a firmware artifact's [`Artifact::content_hash`] /// (which equals tramiton's `Artifact.sha256`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExternalRef { /// Which sibling product this reference points at. pub system: ExternalSystem, /// The sibling product's project identifier, if known. pub project_id: Option, /// Content digest of the subject artifact (firmware sha256), if known. pub subject_sha256: Option, /// Reconciliation status: `linked` | `reconciled` | `unavailable`. #[serde(default)] pub status: String, /// Opaque, offline-verifiable entitlement grant (e.g. tramiton's signed /// `LicenseGrant`), if the tenant provided one. pub license_grant: Option, /// When evidence was last reconciled from this system. #[serde(default, with = "super::serde_helpers::opt_bson_datetime")] pub last_reconciled_at: Option>, } impl ExternalRef { /// A freshly linked (not yet reconciled) reference to a sibling system. pub fn linked(system: ExternalSystem) -> Self { Self { system, project_id: None, subject_sha256: None, status: "linked".to_string(), license_grant: None, last_reconciled_at: None, } } } /// A regulatory / standards framework a target must comply with. Drives which /// controls the mapping engine pulls from the [`crate::traits::ControlsProvider`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ComplianceFramework { /// EU Cyber Resilience Act. Cra, /// IEC 62443 (industrial automation & control systems security). Iec62443, /// EU General Data Protection Regulation. Gdpr, /// SOC 2. Soc2, /// ISO/IEC 27001. Iso27001, /// EU Radio Equipment Directive (RED) cybersecurity articles. RedDirective, } impl std::fmt::Display for ComplianceFramework { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Cra => write!(f, "cra"), Self::Iec62443 => write!(f, "iec_62443"), Self::Gdpr => write!(f, "gdpr"), Self::Soc2 => write!(f, "soc2"), Self::Iso27001 => write!(f, "iso_27001"), Self::RedDirective => write!(f, "red_directive"), } } } /// The compliance scope of a target: which frameworks apply and, optionally, the /// jurisdiction. Captured at onboarding (with per-target-type defaults from /// [`default_compliance_profile`]). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComplianceProfile { /// Applicable frameworks. #[serde(default)] pub frameworks: Vec, /// Free-form jurisdiction (e.g. `eu`, `us`, `de`). pub jurisdiction: Option, } /// The sensible default compliance scope for a target type. Firmware / PLC / /// embedded default to CRA + IEC 62443; software defaults to GDPR + SOC 2. pub fn default_compliance_profile(target_type: TargetType) -> ComplianceProfile { use ComplianceFramework::{Cra, Gdpr, Iec62443, Soc2}; let frameworks = match target_type { TargetType::PlcSps | TargetType::FirmwareBareMetal | TargetType::FirmwareRtos | TargetType::EmbeddedLinuxYocto => vec![Cra, Iec62443], TargetType::WebApp | TargetType::BackendService => vec![Gdpr, Soc2], TargetType::DesktopApp | TargetType::AndroidApp | TargetType::IosApp => { vec![Gdpr, Cra] } }; ComplianceProfile { frameworks, jurisdiction: None, } } /// A target onboarded for scanning: the unified replacement for the legacy /// `TrackedRepository` (SAST) and `DastTarget` (DAST) records. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnboardedTarget { /// Mongo id. Preserved from the legacy record during migration so every /// downstream collection keyed by `repo_id` / `target_id` keeps resolving. #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, /// Human-friendly name. #[serde(default)] pub name: String, /// The software family this target belongs to. pub target_type: TargetType, /// Optional free-form description (also a classification input). pub description: Option, /// The artifacts provided for this target. #[serde(default)] pub artifacts: Vec, /// The classifier's verdict, once run. pub classification: Option, /// How this target should be scanned. #[serde(default)] pub scan_config: TargetScanConfig, /// The compliance scope (applicable frameworks / jurisdiction). #[serde(default)] pub compliance_profile: ComplianceProfile, /// Links to sibling products (tramiton, ...) holding reconcilable evidence. #[serde(default)] pub external_refs: Vec, /// Cron schedule for recurring scans, if any. pub scan_schedule: Option, /// Whether inbound webhooks are enabled for this target. #[serde(default)] pub webhook_enabled: bool, /// HMAC secret for verifying inbound webhooks. #[serde(default, skip_serializing_if = "Option::is_none")] pub webhook_secret: Option, /// Cached count of findings across this target's scans. #[serde(default)] pub findings_count: u32, /// Creation timestamp. #[serde( default = "chrono::Utc::now", with = "super::serde_helpers::bson_datetime" )] pub created_at: DateTime, /// Last-update timestamp. #[serde( default = "chrono::Utc::now", with = "super::serde_helpers::bson_datetime" )] pub updated_at: DateTime, } impl OnboardedTarget { /// A new target of the given type with a freshly generated webhook secret. pub fn new(name: String, target_type: TargetType) -> Self { let now = Utc::now(); let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', ""); Self { id: None, name, target_type, description: None, artifacts: Vec::new(), classification: None, scan_config: TargetScanConfig::default(), compliance_profile: default_compliance_profile(target_type), external_refs: Vec::new(), scan_schedule: None, webhook_enabled: false, webhook_secret: Some(webhook_secret), findings_count: 0, created_at: now, updated_at: now, } } /// The first artifact of the given kind, if present. pub fn first_of(&self, kind: ArtifactKind) -> Option<&Artifact> { self.artifacts.iter().find(|a| a.kind == kind) } /// Whether the target has at least one artifact of the given kind. pub fn has(&self, kind: ArtifactKind) -> bool { self.artifacts.iter().any(|a| a.kind == kind) } /// The primary code artifact (git repo or source archive), if any. pub fn code_artifact(&self) -> Option<&Artifact> { self.artifacts .iter() .find(|a| matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive)) } /// The live-URL artifact, if any. pub fn live_url(&self) -> Option<&Artifact> { self.first_of(ArtifactKind::LiveUrl) } } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; fn sample_target() -> OnboardedTarget { let mut t = OnboardedTarget::new("acme-web".to_string(), TargetType::WebApp); t.artifacts.push(Artifact::git_repo( "https://git.example.com/acme.git", "main", )); t.artifacts .push(Artifact::live_url("https://acme.example.com")); t } #[test] fn onboarded_target_bson_round_trip() { let t = sample_target(); let b = bson::to_bson(&t).expect("serialize"); let back: OnboardedTarget = bson::from_bson(b.clone()).expect("deserialize"); let b2 = bson::to_bson(&back).expect("re-serialize"); assert_eq!(b, b2); } #[test] fn enum_display_is_snake_case() { assert_eq!( TargetType::FirmwareBareMetal.to_string(), "firmware_bare_metal" ); assert_eq!(TargetType::PlcSps.to_string(), "plc_sps"); assert_eq!(ArtifactKind::PlcProject.to_string(), "plc_project"); assert_eq!(ArtifactKind::MobilePackage.to_string(), "mobile_package"); } #[test] fn helpers_locate_artifacts() { let t = sample_target(); assert!(t.has(ArtifactKind::GitRepo)); assert!(t.live_url().is_some()); assert!(t.code_artifact().is_some()); assert!(!t.has(ArtifactKind::FirmwareImage)); assert_eq!( t.first_of(ArtifactKind::GitRepo).map(|a| a.kind), Some(ArtifactKind::GitRepo) ); } #[test] fn new_target_generates_webhook_secret() { let t = OnboardedTarget::new("t".to_string(), TargetType::BackendService); let secret = t.webhook_secret.expect("secret present"); assert_eq!(secret.len(), 32); assert!(!secret.contains('-')); } #[test] fn dast_auth_folds_into_artifact_auth() { let dast = DastAuthConfig { method: "bearer".to_string(), login_url: Some("https://x/login".to_string()), username: Some("user".to_string()), password: Some("pw".to_string()), token: Some("tok".to_string()), headers: None, }; let auth = ArtifactAuth::from(dast); assert_eq!(auth.method, "bearer"); // Bearer token wins over password. assert_eq!(auth.secret.as_deref(), Some("tok")); assert_eq!(auth.login_url.as_deref(), Some("https://x/login")); } #[test] fn each_artifact_gets_a_unique_id() { let a = Artifact::firmware_image("fw.bin"); let b = Artifact::firmware_image("fw.bin"); assert_ne!(a.id, b.id); } #[test] fn firmware_default_profile_is_cra_and_62443() { let p = default_compliance_profile(TargetType::FirmwareBareMetal); assert!(p.frameworks.contains(&ComplianceFramework::Cra)); assert!(p.frameworks.contains(&ComplianceFramework::Iec62443)); } #[test] fn webapp_default_profile_is_gdpr_and_soc2() { let p = default_compliance_profile(TargetType::WebApp); assert!(p.frameworks.contains(&ComplianceFramework::Gdpr)); assert!(p.frameworks.contains(&ComplianceFramework::Soc2)); } #[test] fn new_target_gets_default_profile_and_no_external_refs() { let t = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareRtos); assert!(!t.compliance_profile.frameworks.is_empty()); assert!(t.external_refs.is_empty()); } #[test] fn external_ref_linked_defaults() { let r = ExternalRef::linked(ExternalSystem::Tramiton); assert_eq!(r.system, ExternalSystem::Tramiton); assert_eq!(r.status, "linked"); assert!(r.project_id.is_none()); assert!(r.last_reconciled_at.is_none()); } }