From def7371d6ad48186f04e6f2a7ecd7c1e2b72e395 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:23:51 +0200 Subject: [PATCH 1/2] feat(onboarding): unified multi-target model + scan matrix foundation Backend foundation for the artifact-aware onboarding redesign (epic #118), replacing the git-only TrackedRepository / DastTarget split with a unified OnboardedTarget classified by target type and carrying its artifacts. compliance-core: - OnboardedTarget model: TargetType (9 families), ArtifactKind (8 kinds), Artifact with per-kind config, ArtifactAuth (folds git auth + DastAuthConfig), Classification, TargetScanConfig (reuses pentest + tracker config). - Table-driven scan-applicability matrix: applicable_scans / rules_for / supports_pentest, with SAST umbrella + firmware/PLC/mobile/DAST gated on artifact presence. - TargetClassifier port trait, mirroring the Scanner trait. - Additive ScanType/ScanPhase variants (firmware/PLC/mobile/container); ScanType is now Copy. compliance-agent: - onboarded_targets collection accessor + indexes (artifacts.source_ref, artifacts.kind, target_type). - Drop a now-redundant ScanType clone surfaced by the Copy derive. Foundation only (steps 1-2 of the approved plan); legacy scan paths untouched. 17 new unit tests; passes fmt + clippy -D warnings (agent, dashboard, mcp). Refs #118, #119, #122, #121. Co-Authored-By: Claude Fable 5 --- compliance-agent/src/database.rs | 37 ++ compliance-agent/src/pipeline/patterns.rs | 2 +- compliance-core/src/lib.rs | 1 + compliance-core/src/models/mod.rs | 6 + compliance-core/src/models/onboarding.rs | 587 ++++++++++++++++++++++ compliance-core/src/models/scan.rs | 20 +- compliance-core/src/scan_matrix.rs | 379 ++++++++++++++ compliance-core/src/traits/classifier.rs | 51 ++ compliance-core/src/traits/mod.rs | 2 + 9 files changed, 1083 insertions(+), 2 deletions(-) create mode 100644 compliance-core/src/models/onboarding.rs create mode 100644 compliance-core/src/scan_matrix.rs create mode 100644 compliance-core/src/traits/classifier.rs diff --git a/compliance-agent/src/database.rs b/compliance-agent/src/database.rs index ec279d6..1105a58 100644 --- a/compliance-agent/src/database.rs +++ b/compliance-agent/src/database.rs @@ -428,6 +428,36 @@ impl Database { ) .await?; + // onboarded_targets: multikey on artifact source ref (webhook + dedupe + // lookup). Non-unique — "one git URL per tenant" is enforced in the + // create handler, since a unique multikey index on an array field has + // null-collision caveats. + self.onboarded_targets() + .create_index( + IndexModel::builder() + .keys(doc! { "artifacts.source_ref": 1 }) + .build(), + ) + .await?; + + // onboarded_targets: multikey on artifact kind + self.onboarded_targets() + .create_index( + IndexModel::builder() + .keys(doc! { "artifacts.kind": 1 }) + .build(), + ) + .await?; + + // onboarded_targets: target_type filter + self.onboarded_targets() + .create_index( + IndexModel::builder() + .keys(doc! { "target_type": 1 }) + .build(), + ) + .await?; + tracing::info!("Database indexes ensured"); Ok(()) } @@ -484,6 +514,13 @@ impl Database { self.inner.collection("dast_targets") } + /// The unified onboarding targets that replace `repositories` and + /// `dast_targets`. Ids are preserved from the legacy collections during + /// migration so downstream `repo_id` / `target_id` references keep resolving. + pub fn onboarded_targets(&self) -> Collection { + self.inner.collection("onboarded_targets") + } + pub fn dast_scan_runs(&self) -> Collection { self.inner.collection("dast_scan_runs") } diff --git a/compliance-agent/src/pipeline/patterns.rs b/compliance-agent/src/pipeline/patterns.rs index b3ca394..2b2be66 100644 --- a/compliance-agent/src/pipeline/patterns.rs +++ b/compliance-agent/src/pipeline/patterns.rs @@ -215,7 +215,7 @@ fn scan_with_patterns( repo_id.to_string(), fingerprint, scanner_name.to_string(), - scan_type.clone(), + scan_type, pattern.title.clone(), pattern.description.clone(), pattern.severity.clone(), diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index 6fd7c50..0d88a58 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod db; pub mod error; pub mod models; +pub mod scan_matrix; #[cfg(feature = "telemetry")] pub mod telemetry; pub mod tenant; diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index f6130f1..7032c11 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -9,6 +9,7 @@ pub mod issue; pub mod mcp; pub mod mcp_token; pub mod notification; +pub mod onboarding; pub mod pentest; pub mod repository; pub mod sbom; @@ -31,6 +32,11 @@ pub use issue::{IssueStatus, TrackerIssue, TrackerType}; pub use mcp::{McpServerConfig, McpServerStatus, McpTransport}; pub use mcp_token::{McpToken, McpTokenView}; pub use notification::{CveNotification, NotificationSeverity, NotificationStatus}; +pub use onboarding::{ + Artifact, ArtifactAuth, ArtifactKind, Classification, DetectedFact, GitArtifactConfig, + IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat, TargetScanConfig, + TargetType, TargetTypeCandidate, WebArtifactConfig, +}; pub use pentest::{ AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider, PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats, diff --git a/compliance-core/src/models/onboarding.rs b/compliance-core/src/models/onboarding.rs new file mode 100644 index 0000000..edc54f1 --- /dev/null +++ b/compliance-core/src/models/onboarding.rs @@ -0,0 +1,587 @@ +//! 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, 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, +} + +/// 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 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, + /// 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(), + 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); + } +} diff --git a/compliance-core/src/models/scan.rs b/compliance-core/src/models/scan.rs index fc7e256..6b24d74 100644 --- a/compliance-core/src/models/scan.rs +++ b/compliance-core/src/models/scan.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use super::repository::ScanTrigger; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ScanType { Sast, @@ -16,6 +16,14 @@ pub enum ScanType { SecretDetection, Lint, CodeReview, + /// Static analysis of a firmware image (unpack + component CVE). + FirmwareStatic, + /// Control-logic security analysis of PLC / SPS programs. + PlcControlLogic, + /// Static analysis of a mobile package (APK / AAB / IPA). + MobileStatic, + /// Static analysis of a container image. + ContainerScan, } impl std::fmt::Display for ScanType { @@ -31,6 +39,10 @@ impl std::fmt::Display for ScanType { Self::SecretDetection => write!(f, "secret_detection"), Self::Lint => write!(f, "lint"), Self::CodeReview => write!(f, "code_review"), + Self::FirmwareStatic => write!(f, "firmware_static"), + Self::PlcControlLogic => write!(f, "plc_control_logic"), + Self::MobileStatic => write!(f, "mobile_static"), + Self::ContainerScan => write!(f, "container_scan"), } } } @@ -47,6 +59,8 @@ pub enum ScanRunStatus { #[serde(rename_all = "snake_case")] pub enum ScanPhase { ChangeDetection, + ArtifactIngest, + Classification, Sast, SbomGeneration, CveScanning, @@ -55,6 +69,10 @@ pub enum ScanPhase { LintScanning, CodeReview, GraphBuilding, + FirmwareStatic, + PlcAnalysis, + MobileStatic, + ContainerScan, LlmTriage, IssueCreation, DastScanning, diff --git a/compliance-core/src/scan_matrix.rs b/compliance-core/src/scan_matrix.rs new file mode 100644 index 0000000..b6873d8 --- /dev/null +++ b/compliance-core/src/scan_matrix.rs @@ -0,0 +1,379 @@ +//! The scan-applicability matrix. +//! +//! Which scans are possible for a target is a function of its [`TargetType`] and +//! which [`ArtifactKind`]s are actually present: SAST needs code, DAST needs a +//! running URL, firmware-static analysis needs a firmware image, and so on. This +//! module encodes that as a table — one rule set per target type — and resolves +//! it against a concrete [`OnboardedTarget`] into a list of [`ScanOption`]s the +//! onboarding wizard and the scan pipeline both consume. + +use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType}; + +/// What an artifact a scan needs in order to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtifactRequirement { + /// Source code — a git repo or a source archive. + Code, + /// A reachable running instance (live URL / endpoint). + RunningUrl, + /// A firmware image / binary blob. + Firmware, + /// A PLC project (PLCopen XML or Structured Text). + Plc, + /// A mobile package (APK / AAB / IPA). + Mobile, + /// A container image. + Container, + /// No specific artifact required. + Any, +} + +/// A static rule: this scan applies to a target type, needs this artifact, and +/// defaults on/off. The rationale explains the entry to the user. +#[derive(Debug, Clone, Copy)] +pub struct ScanRule { + /// The scan this rule governs. + pub scan: ScanType, + /// Whether the scan is on by default (only when its artifact is present). + pub default_on: bool, + /// Human-readable explanation of what the scan does here. + pub rationale: &'static str, + /// The artifact the scan consumes. + pub requires: ArtifactRequirement, +} + +impl ScanRule { + const fn new( + scan: ScanType, + default_on: bool, + rationale: &'static str, + requires: ArtifactRequirement, + ) -> Self { + Self { + scan, + default_on, + rationale, + requires, + } + } +} + +/// A resolved scan choice for a specific target: a rule intersected with the +/// artifacts actually present. `blocked_reason` is `Some` when the required +/// artifact is missing. +#[derive(Debug, Clone)] +pub struct ScanOption { + /// The scan. + pub scan: ScanType, + /// Whether to pre-select the scan (false when blocked). + pub default_on: bool, + /// Why the scan is offered. + pub rationale: String, + /// The artifact kind the scan needs, if any specific one. + pub required_artifact: Option, + /// Set when the required artifact is absent, explaining the block. + pub blocked_reason: Option, +} + +/// The SAST umbrella: every static-analysis sub-scan that runs over source code. +fn sast_umbrella() -> Vec { + use ArtifactRequirement::Code; + vec![ + ScanRule::new( + ScanType::Sast, + true, + "Static analysis (Semgrep) over source", + Code, + ), + ScanRule::new( + ScanType::Sbom, + true, + "Software bill of materials from source", + Code, + ), + ScanRule::new( + ScanType::Cve, + true, + "Match dependencies against known CVEs", + Code, + ), + ScanRule::new( + ScanType::SecretDetection, + true, + "Scan source for committed secrets", + Code, + ), + ScanRule::new(ScanType::Lint, true, "Language linters over source", Code), + ScanRule::new( + ScanType::Gdpr, + true, + "GDPR data-handling pattern checks", + Code, + ), + ScanRule::new( + ScanType::OAuth, + true, + "OAuth misconfiguration patterns", + Code, + ), + ScanRule::new( + ScanType::Graph, + true, + "Build the code graph for impact analysis", + Code, + ), + ScanRule::new( + ScanType::CodeReview, + false, + "LLM code review over changed source", + Code, + ), + ] +} + +/// The rule set for a target type. Scans that are never applicable to a type are +/// simply absent (e.g. DAST is not listed for a PLC target). +pub fn rules_for(target_type: TargetType) -> Vec { + use ArtifactRequirement::{Firmware, Mobile, Plc, RunningUrl}; + match target_type { + TargetType::WebApp | TargetType::BackendService => { + let mut r = sast_umbrella(); + r.push(ScanRule::new( + ScanType::Dast, + true, + "Dynamic scan of the running endpoint", + RunningUrl, + )); + r + } + TargetType::DesktopApp => sast_umbrella(), + TargetType::AndroidApp | TargetType::IosApp => { + let mut r = sast_umbrella(); + r.push(ScanRule::new( + ScanType::MobileStatic, + true, + "Static analysis of the mobile package (manifest, permissions, libs)", + Mobile, + )); + r + } + TargetType::FirmwareBareMetal | TargetType::FirmwareRtos => { + let mut r = sast_umbrella(); + r.push(ScanRule::new( + ScanType::FirmwareStatic, + true, + "Unpack and statically analyze the firmware image", + Firmware, + )); + r.push(ScanRule::new( + ScanType::Sbom, + true, + "SBOM from the firmware image (binwalk / tramiton)", + Firmware, + )); + r.push(ScanRule::new( + ScanType::Cve, + true, + "Match firmware components against known CVEs", + Firmware, + )); + r + } + TargetType::EmbeddedLinuxYocto => { + let mut r = sast_umbrella(); + r.push(ScanRule::new( + ScanType::FirmwareStatic, + true, + "EMBA / binwalk static analysis of the image", + Firmware, + )); + r.push(ScanRule::new( + ScanType::Sbom, + true, + "SBOM from image layers / recipes", + Firmware, + )); + r.push(ScanRule::new( + ScanType::Cve, + true, + "Match image components against known CVEs", + Firmware, + )); + r.push(ScanRule::new( + ScanType::Dast, + false, + "Dynamic scan of exposed network services (if any)", + RunningUrl, + )); + r + } + TargetType::PlcSps => vec![ScanRule::new( + ScanType::PlcControlLogic, + true, + "Control-logic security rules over the PLC program", + Plc, + )], + } +} + +/// Whether an active penetration test is applicable to this target type. +/// +/// Pentest runs as its own session (not a [`ScanType`] scan) and needs a +/// reachable running target, so it is offered only for the network-reachable +/// families. +pub fn supports_pentest(target_type: TargetType) -> bool { + matches!( + target_type, + TargetType::WebApp + | TargetType::BackendService + | TargetType::AndroidApp + | TargetType::IosApp + | TargetType::EmbeddedLinuxYocto + ) +} + +/// The representative artifact kind a requirement is satisfied by. +fn representative_kind(req: ArtifactRequirement) -> Option { + match req { + ArtifactRequirement::Code => Some(ArtifactKind::GitRepo), + ArtifactRequirement::RunningUrl => Some(ArtifactKind::LiveUrl), + ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage), + ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject), + ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage), + ArtifactRequirement::Container => Some(ArtifactKind::ContainerImage), + ArtifactRequirement::Any => None, + } +} + +/// Whether the target carries an artifact that satisfies the requirement. +fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool { + match req { + ArtifactRequirement::Code => target.code_artifact().is_some(), + ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl), + ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage), + ArtifactRequirement::Plc => target.has(ArtifactKind::PlcProject), + ArtifactRequirement::Mobile => target.has(ArtifactKind::MobilePackage), + ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage), + ArtifactRequirement::Any => true, + } +} + +/// Resolve the matrix for a concrete target into the scans it can run, marking +/// any whose required artifact is missing as blocked. +pub fn applicable_scans(target: &OnboardedTarget) -> Vec { + rules_for(target.target_type) + .into_iter() + .map(|rule| { + let satisfied = requirement_satisfied(rule.requires, target); + let required_artifact = representative_kind(rule.requires); + let blocked_reason = if satisfied { + None + } else { + Some(match required_artifact { + Some(kind) => format!("no {kind} artifact provided"), + None => "required artifact missing".to_string(), + }) + }; + ScanOption { + scan: rule.scan, + default_on: rule.default_on && satisfied, + rationale: rule.rationale.to_string(), + required_artifact, + blocked_reason, + } + }) + .collect() +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use crate::models::{Artifact, PlcFormat}; + + fn target_with(target_type: TargetType, artifacts: Vec) -> OnboardedTarget { + let mut t = OnboardedTarget::new("t".to_string(), target_type); + t.artifacts = artifacts; + t + } + + fn option<'a>(opts: &'a [ScanOption], scan: ScanType) -> Option<&'a ScanOption> { + opts.iter().find(|o| o.scan == scan) + } + + #[test] + fn webapp_with_code_and_url_offers_sast_and_dast() { + let t = target_with( + TargetType::WebApp, + vec![ + Artifact::git_repo("u", "main"), + Artifact::live_url("http://x"), + ], + ); + let opts = applicable_scans(&t); + let sast = option(&opts, ScanType::Sast).expect("sast offered"); + assert!(sast.default_on && sast.blocked_reason.is_none()); + let dast = option(&opts, ScanType::Dast).expect("dast offered"); + assert!(dast.default_on && dast.blocked_reason.is_none()); + } + + #[test] + fn webapp_without_url_blocks_dast() { + let t = target_with(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]); + let opts = applicable_scans(&t); + let dast = option(&opts, ScanType::Dast).expect("dast listed"); + assert!(!dast.default_on); + assert!(dast.blocked_reason.is_some()); + assert_eq!(dast.required_artifact, Some(ArtifactKind::LiveUrl)); + } + + #[test] + fn firmware_offers_firmware_static_and_not_dast() { + let t = target_with( + TargetType::FirmwareBareMetal, + vec![Artifact::firmware_image("fw.bin")], + ); + let opts = applicable_scans(&t); + let fw = option(&opts, ScanType::FirmwareStatic).expect("firmware static offered"); + assert!(fw.default_on && fw.blocked_reason.is_none()); + assert!(option(&opts, ScanType::Dast).is_none()); + } + + #[test] + fn plc_offers_only_control_logic() { + let t = target_with( + TargetType::PlcSps, + vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)], + ); + let opts = applicable_scans(&t); + assert_eq!(opts.len(), 1); + assert_eq!(opts[0].scan, ScanType::PlcControlLogic); + assert!(opts[0].default_on); + } + + #[test] + fn pentest_support_matches_reachable_families() { + assert!(supports_pentest(TargetType::WebApp)); + assert!(supports_pentest(TargetType::BackendService)); + assert!(!supports_pentest(TargetType::PlcSps)); + assert!(!supports_pentest(TargetType::FirmwareBareMetal)); + assert!(!supports_pentest(TargetType::DesktopApp)); + } + + #[test] + fn every_target_type_has_at_least_one_rule() { + for tt in [ + TargetType::WebApp, + TargetType::BackendService, + TargetType::DesktopApp, + TargetType::AndroidApp, + TargetType::IosApp, + TargetType::FirmwareBareMetal, + TargetType::FirmwareRtos, + TargetType::EmbeddedLinuxYocto, + TargetType::PlcSps, + ] { + assert!(!rules_for(tt).is_empty(), "{tt} has no rules"); + } + } +} diff --git a/compliance-core/src/traits/classifier.rs b/compliance-core/src/traits/classifier.rs new file mode 100644 index 0000000..fc89146 --- /dev/null +++ b/compliance-core/src/traits/classifier.rs @@ -0,0 +1,51 @@ +//! The target-classification port. +//! +//! A [`TargetClassifier`] inspects a target's artifacts (and optionally their +//! ingested working directories) and proposes one or more [`ClassifierVerdict`]s +//! — a target type, a confidence, and the facts the decision rested on. Concrete +//! classifiers live in the agent (language/build-system fingerprinting, a +//! firmware detector backed by tramiton, etc.); a registry merges and ranks +//! their verdicts. This mirrors the [`crate::traits::Scanner`] port so the two +//! read the same way. + +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::error::CoreError; +use crate::models::{Artifact, DetectedFact, TargetType}; + +/// Everything a classifier needs to reason about a target. +pub struct ClassificationInput<'a> { + /// The artifacts declared for the target. + pub artifacts: &'a [Artifact], + /// Ingested working paths, keyed by [`Artifact::id`]. Absent for artifacts + /// with no on-disk form (e.g. a live URL). + pub working_paths: &'a HashMap, + /// Free-form description of the target, if provided. + pub description: Option<&'a str>, +} + +/// A single classifier's proposal for a target. +pub struct ClassifierVerdict { + /// The proposed target type. + pub target_type: TargetType, + /// Confidence in `[0.0, 1.0]`. + pub confidence: f32, + /// Facts that informed the proposal. + pub facts: Vec, + /// Human-readable explanation. + pub rationale: String, +} + +/// A source of target-type classification. +#[allow(async_fn_in_trait)] +pub trait TargetClassifier: Send + Sync { + /// Stable identifier for this classifier (recorded in `detected_by`). + fn name(&self) -> &str; + + /// Propose zero or more ranked verdicts for the given input. + async fn classify( + &self, + input: &ClassificationInput<'_>, + ) -> Result, CoreError>; +} diff --git a/compliance-core/src/traits/mod.rs b/compliance-core/src/traits/mod.rs index 4677153..d963b6c 100644 --- a/compliance-core/src/traits/mod.rs +++ b/compliance-core/src/traits/mod.rs @@ -1,9 +1,11 @@ +pub mod classifier; pub mod dast_agent; pub mod graph_builder; pub mod issue_tracker; pub mod pentest_tool; pub mod scanner; +pub use classifier::{ClassificationInput, ClassifierVerdict, TargetClassifier}; pub use dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter}; pub use graph_builder::{LanguageParser, ParseOutput}; pub use issue_tracker::IssueTracker; -- 2.54.0 From e99c34630e947e4c50ee79e4994c314acc20e402 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:30:45 +0200 Subject: [PATCH 2/2] fix(audit): bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204) Invalid pointer dereference in the `fmt::Pointer` impl for `Atomic`/`Shared` when the underlying pointer is invalid. Lockfile-only bump; no code changes. Refs #118. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe8a944..2c4addf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,9 +1118,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] -- 2.54.0