gossan-checkpoint 0.3.2

Scan checkpoint and resume for gossan — persists stage results to SQLite — part of the security research ecosystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
#![forbid(unsafe_code)]
// pedantic moved to workspace [lints.clippy] in root Cargo.toml
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::todo,
        clippy::unimplemented,
        clippy::panic
    )
)]
#![allow(
    clippy::module_name_repetitions,
    clippy::must_use_candidate,
    clippy::missing_errors_doc
)]

//! Scan checkpoint and resume — persists stage results to a local SQLite file.
//!
//! # Usage
//! ```ignore
//! let store = CheckpointStore::open("gossan-scan.db")?;
//! let scan_id = store.new_scan("example.com", &config_json)?;
//!
//! // After each stage:
//! store.save_stage(scan_id, "subdomain", &targets, &findings)?;
//!
//! // On resume:
//! let record = store.load(scan_id)?;
//! if let Some(stage) = record.stage("subdomain") {
//!     // skip subdomain scan, restore targets
//! }
//! ```

use std::path::Path;

use anyhow::Context;
use chrono::Utc;
use gossan_core::Target;
use rusqlite::{params, Connection};
use secfinding::Finding;
use uuid::Uuid;

/// Persistent scan store backed by SQLite.
pub struct CheckpointStore {
    conn: Connection,
}

/// A single completed pipeline stage stored in the checkpoint.
pub struct StageRecord {
    pub stage: String,
    pub targets: Vec<Target>,
    pub findings: Vec<Finding>,
    pub completed_at: String,
}

/// All data for a saved scan — used to restore state on `--resume`.
pub struct ScanRecord {
    pub scan_id: Uuid,
    pub seed: String,
    pub stages: Vec<StageRecord>,
}

impl ScanRecord {
    /// Return the stage record for `name` if it was completed and saved.
    pub fn stage(&self, name: &str) -> Option<&StageRecord> {
        self.stages.iter().find(|s| s.stage == name)
    }
}

impl CheckpointStore {
    /// Open (or create) the SQLite checkpoint database at `path`.
    pub fn open(path: impl AsRef<Path>) -> anyhow::Result<Self> {
        let conn = Connection::open(path).context("opening checkpoint database")?;
        conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS scans (
                scan_id    TEXT PRIMARY KEY,
                seed       TEXT NOT NULL,
                config     TEXT NOT NULL,
                created_at TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS stages (
                id            INTEGER PRIMARY KEY AUTOINCREMENT,
                scan_id       TEXT NOT NULL REFERENCES scans(scan_id),
                stage         TEXT NOT NULL,
                targets_json  TEXT NOT NULL,
                findings_json TEXT NOT NULL,
                completed_at  TEXT NOT NULL,
                UNIQUE(scan_id, stage)
            );",
        )
        .context("initialising checkpoint schema")?;
        Ok(Self { conn })
    }

    /// Create a new scan record and return its UUID.
    pub fn new_scan(&self, seed: &str, config_json: &str) -> anyhow::Result<Uuid> {
        let id = Uuid::new_v4();
        self.conn.execute(
            "INSERT INTO scans (scan_id, seed, config, created_at) VALUES (?1, ?2, ?3, ?4)",
            params![id.to_string(), seed, config_json, Utc::now().to_rfc3339()],
        )?;
        Ok(id)
    }

    /// Persist a completed stage.
    pub fn save_stage(
        &self,
        scan_id: Uuid,
        stage: &str,
        targets: &[Target],
        findings: &[Finding],
    ) -> anyhow::Result<()> {
        let targets_json = serde_json::to_string(targets)?;
        let findings_json = serde_json::to_string(findings)?;
        self.conn.execute(
            "INSERT OR REPLACE INTO stages
             (scan_id, stage, targets_json, findings_json, completed_at)
             VALUES (?1, ?2, ?3, ?4, ?5)",
            params![
                scan_id.to_string(),
                stage,
                targets_json,
                findings_json,
                Utc::now().to_rfc3339()
            ],
        )?;
        tracing::debug!(scan_id = %scan_id, stage, "checkpoint saved");
        Ok(())
    }

    /// Load all stage records for a given scan UUID.
    pub fn load(&self, scan_id: Uuid) -> anyhow::Result<ScanRecord> {
        let seed: String = self
            .conn
            .query_row(
                "SELECT seed FROM scans WHERE scan_id = ?1",
                params![scan_id.to_string()],
                |row| row.get(0),
            )
            .context("scan not found")?;

        let mut stmt = self.conn.prepare(
            "SELECT stage, targets_json, findings_json, completed_at
             FROM stages WHERE scan_id = ?1 ORDER BY id",
        )?;

        // Collect raw rows first (can't deserialize inside the rusqlite closure due to borrow rules)
        let raw_rows: Vec<(String, String, String, String)> = stmt
            .query_map(params![scan_id.to_string()], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                    row.get::<_, String>(3)?,
                ))
            })?
            .collect::<Result<_, _>>()?;

        let stages = raw_rows
            .into_iter()
            .map(
                |(stage, t_json, f_json, completed_at)| -> anyhow::Result<StageRecord> {
                    Ok(StageRecord {
                        stage,
                        targets: serde_json::from_str(&t_json)?,
                        findings: serde_json::from_str(&f_json)?,
                        completed_at,
                    })
                },
            )
            .collect::<anyhow::Result<Vec<_>>>()?;

        Ok(ScanRecord {
            scan_id,
            seed,
            stages,
        })
    }

    /// Delete a scan and all its stage records.
    pub fn delete_scan(&self, scan_id: Uuid) -> anyhow::Result<()> {
        self.conn.execute(
            "DELETE FROM stages WHERE scan_id = ?1",
            params![scan_id.to_string()],
        )?;
        self.conn.execute(
            "DELETE FROM scans  WHERE scan_id = ?1",
            params![scan_id.to_string()],
        )?;
        Ok(())
    }

    /// List all saved scan IDs and seeds (for `gossan list-scans`).
    pub fn list_scans(&self) -> anyhow::Result<Vec<(Uuid, String, String)>> {
        let mut stmt = self
            .conn
            .prepare("SELECT scan_id, seed, created_at FROM scans ORDER BY created_at DESC")?;
        let rows = stmt
            .query_map([], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                ))
            })?
            .filter_map(|r| r.ok())
            .filter_map(|(id, seed, ts)| Uuid::parse_str(&id).ok().map(|u| (u, seed, ts)))
            .collect();
        Ok(rows)
    }
}

#[cfg(test)]
mod tests {
    use gossan_core::{DiscoverySource, DomainTarget, Target};
    use secfinding::{Finding, Severity};

    use super::*;

    fn in_memory() -> CheckpointStore {
        CheckpointStore::open(":memory:").expect("in-memory store")
    }

    fn make_target(domain: &str) -> Target {
        Target::Domain(DomainTarget {
            domain: domain.into(),
            source: DiscoverySource::Seed,
        })
    }

    fn make_finding(title: &str) -> Finding {
        Finding::builder("portscan", "example.com", Severity::High)
            .title(title)
            .detail("detail")
            .build()
            .expect("required finding fields")
    }

    #[test]
    fn new_scan_creates_record() {
        let store = in_memory();
        let id = store.new_scan("example.com", "{}").unwrap();
        let scans = store.list_scans().unwrap();
        assert_eq!(scans.len(), 1);
        assert_eq!(scans[0].0, id);
        assert_eq!(scans[0].1, "example.com");
    }

    #[test]
    fn save_and_load_stage() {
        let store = in_memory();
        let id = store.new_scan("example.com", "{}").unwrap();

        let targets = vec![make_target("api.example.com")];
        let findings = vec![make_finding("Open port 443")];
        store
            .save_stage(id, "portscan", &targets, &findings)
            .unwrap();

        let record = store.load(id).unwrap();
        assert_eq!(record.seed, "example.com");
        let stage = record.stage("portscan").expect("stage should exist");
        assert_eq!(stage.targets.len(), 1);
        assert_eq!(stage.findings.len(), 1);
        assert_eq!(stage.findings[0].title(), "Open port 443");
    }

    #[test]
    fn stage_not_found_returns_none() {
        let store = in_memory();
        let id = store.new_scan("example.com", "{}").unwrap();
        let record = store.load(id).unwrap();
        assert!(record.stage("subdomain").is_none());
    }

    #[test]
    fn save_stage_is_idempotent() {
        let store = in_memory();
        let id = store.new_scan("example.com", "{}").unwrap();
        store.save_stage(id, "dns", &[], &[]).unwrap();
        // Second save should replace (INSERT OR REPLACE), not error
        store
            .save_stage(id, "dns", &[make_target("example.com")], &[])
            .unwrap();
        let record = store.load(id).unwrap();
        assert_eq!(record.stage("dns").unwrap().targets.len(), 1);
    }

    #[test]
    fn load_missing_scan_errors() {
        let store = in_memory();
        let fake_id = Uuid::new_v4();
        assert!(store.load(fake_id).is_err());
    }

    #[test]
    fn delete_scan_removes_all_records() {
        let store = in_memory();
        let id = store.new_scan("example.com", "{}").unwrap();
        store.save_stage(id, "dns", &[], &[]).unwrap();
        store.delete_scan(id).unwrap();
        assert!(store.list_scans().unwrap().is_empty());
        assert!(store.load(id).is_err());
    }

    #[test]
    fn scan_record_stage_returns_matching_stage() {
        let record = ScanRecord {
            scan_id: Uuid::new_v4(),
            seed: "example.com".into(),
            stages: vec![StageRecord {
                stage: "dns".into(),
                targets: vec![],
                findings: vec![],
                completed_at: "now".into(),
            }],
        };
        assert_eq!(record.stage("dns").unwrap().stage, "dns");
    }

    #[test]
    fn list_scans_returns_multiple_entries() {
        let store = in_memory();
        store.new_scan("one.example", "{}").unwrap();
        store.new_scan("two.example", "{}").unwrap();
        assert_eq!(store.list_scans().unwrap().len(), 2);
    }
}

/// Finding delta between two scan runs.
#[derive(Debug)]
pub struct ScanDelta {
    /// Findings present in new scan but not in baseline.
    pub new_findings: Vec<Finding>,
    /// Findings present in baseline but not in new scan (resolved).
    pub resolved_findings: Vec<Finding>,
    /// Findings present in both (unchanged).
    pub unchanged_count: usize,
}

/// Compare findings from a new scan against a baseline scan.
///
/// Uses (scanner, target, title) as the identity key for matching.
/// This enables delta reporting: "what changed since last scan?"
pub fn diff_findings(baseline: &[Finding], current: &[Finding]) -> ScanDelta {
    use std::collections::HashSet;

    // Key: (scanner, target, title) — uniquely identifies a finding class
    let baseline_keys: HashSet<(String, String, String)> = baseline
        .iter()
        .map(|f| {
            (
                f.scanner().to_string(),
                f.target().to_string(),
                f.title().to_string(),
            )
        })
        .collect();

    let current_keys: HashSet<(String, String, String)> = current
        .iter()
        .map(|f| {
            (
                f.scanner().to_string(),
                f.target().to_string(),
                f.title().to_string(),
            )
        })
        .collect();

    let new_findings: Vec<Finding> = current
        .iter()
        .filter(|f| {
            !baseline_keys.contains(&(
                f.scanner().to_string(),
                f.target().to_string(),
                f.title().to_string(),
            ))
        })
        .cloned()
        .collect();

    let resolved_findings: Vec<Finding> = baseline
        .iter()
        .filter(|f| {
            !current_keys.contains(&(
                f.scanner().to_string(),
                f.target().to_string(),
                f.title().to_string(),
            ))
        })
        .cloned()
        .collect();

    let unchanged_count = current.len() - new_findings.len();

    ScanDelta {
        new_findings,
        resolved_findings,
        unchanged_count,
    }
}