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
380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
//! 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");
|
|
}
|
|
}
|
|
}
|