droidsaw 2.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! Threat-model module — composition root for the unsigned-evidence
//! envelope producer + STIX consumer.
//!
//! The types this module operates over live in `droidsaw_common::threat_model`;
//! algorithms (NDJSON canonicalization, content hashing, STIX mapping) live
//! here.

pub mod envelope;
pub mod stix;

/// Errors surfaced by the threat-model module's algorithms.
///
/// Typed `Err` carrier — no `unwrap` / `expect` / `panic` is allowed on the
/// envelope or STIX paths. All error paths use typed error enums for safety.
#[derive(Debug, thiserror::Error)]
pub enum ThreatModelError {
    /// SQLite-side error while reading findings or writing the migration.
    #[error("threat-model: sqlite: {0}")]
    Sqlite(#[from] rusqlite::Error),

    /// JSON serialization or deserialization error during canonicalization
    /// or STIX parsing.
    #[error("threat-model: json: {0}")]
    Json(#[from] serde_json::Error),

    /// Underlying I/O error (envelope write, STIX-feed read).
    #[error("threat-model: io: {0}")]
    Io(#[from] std::io::Error),

    /// STIX 2.1 parse error — bundle JSON was not well-formed or required
    /// fields missing. Carries the originating path for error attribution
    /// and the inner `serde_json::Error` via `#[source]` so the parser's
    /// line/column info survives the boundary instead of being flattened
    /// to its `Display` form.
    #[error("threat-model: STIX parse: {path}: {source}")]
    StixParse {
        /// Path of the STIX bundle file that failed to parse.
        path: std::path::PathBuf,
        /// Inner JSON parser error (line/column preserved).
        #[source]
        source: serde_json::Error,
    },

    /// SQLite findings-db build pipeline failed; the inner anyhow chain is
    /// preserved via `#[source]` so the cause (sqlite-prepare error, missing
    /// schema, etc.) survives the boundary. Previously this was collapsed
    /// to a String inside `Io(io::Error::other(format!("{e:#}")))`.
    #[error("threat-model: findings-db build failed — {0:#}")]
    DbBuildFailed(#[source] anyhow::Error),

    /// Caller-provided value did not match a known enum variant.
    /// Used by acquisition-metadata flag parsing in `commands/audit`.
    #[error("threat-model: invalid value for {field}: {value}")]
    InvalidValue {
        /// Field name (`acquired-from`, `acquired-at`, …).
        field: &'static str,
        /// Offending value.
        value: String,
    },
}

/// Convenience alias.
pub type Result<T> = std::result::Result<T, ThreatModelError>;

/// Build an `AcquisitionMetadata` from CLI-supplied strings.
///
/// Each argument corresponds to one of the four threat-model audit flags.
/// `None` ⇒ `None` on the matching field; an unrecognized `acquired_from`
/// or malformed RFC-3339 in `acquired_at` returns
/// [`ThreatModelError::InvalidValue`] — never a panic.
pub fn parse_acquisition_metadata(
    acquired_from: Option<&str>,
    operator: Option<&str>,
    case_ref: Option<&str>,
    acquired_at: Option<&str>,
) -> Result<droidsaw_common::threat_model::AcquisitionMetadata> {
    use droidsaw_common::threat_model::{AcquisitionMetadata, AcquisitionSource};

    let source_kind = match acquired_from {
        None => AcquisitionSource::Unknown,
        Some("adb_pull") => AcquisitionSource::AdbPull,
        Some("file_upload") => AcquisitionSource::FileUpload,
        Some("download_url") => AcquisitionSource::DownloadUrl,
        Some("device_image") => AcquisitionSource::DeviceImage,
        Some("unknown") => AcquisitionSource::Unknown,
        Some(other) => {
            return Err(ThreatModelError::InvalidValue {
                field: "acquired-from",
                value: other.to_string(),
            })
        }
    };

    let acquired_at_parsed = match acquired_at {
        None => None,
        Some(s) => Some(s.parse::<chrono::DateTime<chrono::Utc>>().map_err(|e| {
            ThreatModelError::InvalidValue {
                field: "acquired-at",
                value: format!("{s} ({e})"),
            }
        })?),
    };

    Ok(AcquisitionMetadata {
        source_kind,
        operator: operator.map(str::to_string),
        authority_ref: case_ref.map(str::to_string),
        acquired_at: acquired_at_parsed,
        pre_analysis_hash: None,
    })
}

/// Run the unsigned-evidence pipeline end-to-end: build a v2 findings DB
/// from the supplied findings, produce the canonical envelope, and write
/// `envelope.json` + `findings.ndjson` to `out_dir`.
///
/// Returns the on-disk paths of the two produced files for caller logging.
/// `out_dir` is created if it does not exist; existing files there are
/// overwritten.
pub fn write_unsigned_evidence(
    findings: &[droidsaw_common::Finding],
    acquisition: &droidsaw_common::threat_model::AcquisitionMetadata,
    tool_version: &str,
    out_dir: &std::path::Path,
) -> Result<UnsignedEvidencePaths> {
    std::fs::create_dir_all(out_dir)?;

    // Write the canonical findings DB inside the output dir. The DB is a
    // by-product (not the operator's primary output) but keeping it next to
    // the envelope makes after-the-fact analysis easy. The migrator runs as
    // part of `write_findings_db`, so the resulting DB is at v2.
    let db_path = out_dir.join("findings.db");
    // WHY: best-effort overwrite-prep (NotFound is fine; failures fall
    // through to the open below which produces a typed error).
    drop(std::fs::remove_file(&db_path));
    crate::commands::write_findings_db(findings, &db_path)
        .map_err(ThreatModelError::DbBuildFailed)?;

    let conn = rusqlite::Connection::open(&db_path)?;
    let env = envelope::produce_unsigned_envelope(&conn, acquisition, tool_version)?;

    let envelope_json_path = out_dir.join("envelope.json");
    let findings_ndjson_path = out_dir.join("findings.ndjson");
    std::fs::write(&envelope_json_path, serde_json::to_vec_pretty(&env)?)?;
    std::fs::write(&findings_ndjson_path, &env.findings_ndjson)?;

    Ok(UnsignedEvidencePaths {
        envelope_json: envelope_json_path,
        findings_ndjson: findings_ndjson_path,
        findings_db: db_path,
        finding_count: env.finding_count,
        finding_set_hash: env.finding_set_hash,
    })
}

/// Where each artifact of [`write_unsigned_evidence`] landed on disk plus
/// the envelope summary the caller can log to stderr.
#[derive(Debug, Clone)]
pub struct UnsignedEvidencePaths {
    /// `<out>/envelope.json`.
    pub envelope_json: std::path::PathBuf,
    /// `<out>/findings.ndjson`.
    pub findings_ndjson: std::path::PathBuf,
    /// `<out>/findings.db` (the canonical sqlite DB the envelope was
    /// derived from).
    pub findings_db: std::path::PathBuf,
    /// How many findings made it into the envelope.
    pub finding_count: u64,
    /// SHA-256 of the canonical NDJSON, hex-encoded.
    pub finding_set_hash: String,
}