feat(onboarding): unified multi-target model + scan matrix foundation (#134)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m36s
CI / Deploy Dashboard (push) Successful in 7m9s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m44s

This commit was merged in pull request #134.
This commit is contained in:
2026-07-10 13:41:25 +00:00
parent aed551231c
commit 675c4ef699
10 changed files with 1085 additions and 4 deletions
Generated
+2 -2
View File
@@ -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",
]
+37
View File
@@ -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<OnboardedTarget> {
self.inner.collection("onboarded_targets")
}
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
self.inner.collection("dast_scan_runs")
}
+1 -1
View File
@@ -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(),
+1
View File
@@ -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;
+6
View File
@@ -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,
+587
View File
@@ -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<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,
}
/// 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 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,
/// 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(),
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);
}
}
+19 -1
View File
@@ -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,
+379
View File
@@ -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<ArtifactKind>,
/// Set when the required artifact is absent, explaining the block.
pub blocked_reason: Option<String>,
}
/// The SAST umbrella: every static-analysis sub-scan that runs over source code.
fn sast_umbrella() -> Vec<ScanRule> {
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<ScanRule> {
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<ArtifactKind> {
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<ScanOption> {
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<Artifact>) -> 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");
}
}
}
+51
View File
@@ -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<String, PathBuf>,
/// 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<DetectedFact>,
/// 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<Vec<ClassifierVerdict>, CoreError>;
}
+2
View File
@@ -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;