CI / Check (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Deploy Agent (push) Blocked by required conditions
CI / Deploy Dashboard (push) Blocked by required conditions
CI / Deploy Docs (push) Blocked by required conditions
CI / Deploy MCP (push) Blocked by required conditions
757 lines
27 KiB
Rust
757 lines
27 KiB
Rust
//! 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<String>,
|
|
/// The secret credential: PAT, password, or bearer token. Encrypted at rest.
|
|
pub secret: Option<String>,
|
|
/// Path to an SSH private key for git-over-SSH.
|
|
pub ssh_key_path: Option<String>,
|
|
/// Login URL for form-based authentication.
|
|
pub login_url: Option<String>,
|
|
/// Extra headers to send when authenticating / probing.
|
|
pub headers: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
impl From<DastAuthConfig> 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<String>,
|
|
/// Local clone path once the repo has been fetched.
|
|
pub local_path: Option<String>,
|
|
}
|
|
|
|
impl GitArtifactConfig {
|
|
/// Config for a fresh git artifact on the given branch.
|
|
pub fn on_branch(branch: impl Into<String>) -> 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<String>,
|
|
/// 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<String>,
|
|
value: impl Into<String>,
|
|
source: impl Into<String>,
|
|
) -> 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<String>,
|
|
/// Content-addressed storage path once ingested (blobs only).
|
|
pub stored_path: Option<String>,
|
|
/// SHA-256 of the ingested content (git artifacts store the head SHA).
|
|
pub content_hash: Option<String>,
|
|
/// Size of the stored blob in bytes.
|
|
pub size_bytes: Option<u64>,
|
|
/// Credentials for fetching or probing this artifact.
|
|
pub auth: Option<ArtifactAuth>,
|
|
/// Git configuration (present for [`ArtifactKind::GitRepo`]).
|
|
pub git: Option<GitArtifactConfig>,
|
|
/// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]).
|
|
pub web: Option<WebArtifactConfig>,
|
|
/// PLC configuration (present for [`ArtifactKind::PlcProject`]).
|
|
pub plc: Option<PlcArtifactConfig>,
|
|
/// Facts discovered about this artifact by ingest / classification.
|
|
#[serde(default)]
|
|
pub detected: Vec<DetectedFact>,
|
|
/// When this artifact was last ingested.
|
|
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
|
pub ingested_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
impl Artifact {
|
|
/// A bare artifact of the given kind and source reference.
|
|
fn bare(kind: ArtifactKind, source_ref: impl Into<String>) -> 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<String>, branch: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> Self {
|
|
Self::bare(ArtifactKind::MobilePackage, source_ref)
|
|
}
|
|
|
|
/// A container-image artifact referenced by OCI ref.
|
|
pub fn container_image(source_ref: impl Into<String>) -> Self {
|
|
Self::bare(ArtifactKind::ContainerImage, source_ref)
|
|
}
|
|
|
|
/// A PLC-project artifact in the given format.
|
|
pub fn plc_project(source_ref: impl Into<String>, 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<String>) -> 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<TargetTypeCandidate>,
|
|
/// Facts gathered during classification.
|
|
#[serde(default)]
|
|
pub facts: Vec<DetectedFact>,
|
|
/// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`).
|
|
#[serde(default)]
|
|
pub detected_by: Vec<String>,
|
|
/// When classification ran.
|
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
|
pub detected_at: DateTime<Utc>,
|
|
/// 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<TrackerType>,
|
|
/// Tracker owner / organization.
|
|
pub owner: Option<String>,
|
|
/// Tracker repository / project.
|
|
pub repo: Option<String>,
|
|
/// Per-target tracker access token.
|
|
pub token: Option<String>,
|
|
}
|
|
|
|
/// 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<ScanType>,
|
|
/// Scans explicitly turned off.
|
|
#[serde(default)]
|
|
pub disabled_scans: Vec<ScanType>,
|
|
/// 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<PentestStrategy>,
|
|
/// Full pentest wizard configuration.
|
|
pub pentest: Option<PentestConfig>,
|
|
/// Issue-tracker linkage.
|
|
pub issue_tracker: Option<IssueTrackerConfig>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Content digest of the subject artifact (firmware sha256), if known.
|
|
pub subject_sha256: Option<String>,
|
|
/// 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<String>,
|
|
/// When evidence was last reconciled from this system.
|
|
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
|
pub last_reconciled_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
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<ComplianceFramework>,
|
|
/// Free-form jurisdiction (e.g. `eu`, `us`, `de`).
|
|
pub jurisdiction: Option<String>,
|
|
}
|
|
|
|
/// 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<bson::oid::ObjectId>,
|
|
/// 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<String>,
|
|
/// The artifacts provided for this target.
|
|
#[serde(default)]
|
|
pub artifacts: Vec<Artifact>,
|
|
/// The classifier's verdict, once run.
|
|
pub classification: Option<Classification>,
|
|
/// 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<ExternalRef>,
|
|
/// Cron schedule for recurring scans, if any.
|
|
pub scan_schedule: Option<String>,
|
|
/// 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<String>,
|
|
/// 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<Utc>,
|
|
/// Last-update timestamp.
|
|
#[serde(
|
|
default = "chrono::Utc::now",
|
|
with = "super::serde_helpers::bson_datetime"
|
|
)]
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|