Skip to content

Commit db311fc

Browse files
h4x0rclaude
andcommitted
feat(sqlite-forensic): surface recovered dropped schemas in audit
Add AnomalyKind::DroppedSchemaRecovered (code SQLITE-DROPPED-SCHEMA-RECOVERED, Medium, Residue) and emit one per recover_dropped_schemas hit in audit(), so the CLI `audit` command reports a dropped table/index/view/trigger's name + that its definition survived the drop — a graded observation ("consistent with a dropped <name>"), never a verdict. - audit_surfaces_the_dropped_schema_as_a_finding pins it on the fixture. - docs/index.md: anomaly-code table row. - 100% function coverage; full workspace green; rustdoc/MSRV/mkdocs clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent caec699 commit db311fc

3 files changed

Lines changed: 58 additions & 1 deletion

File tree

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ By default the `sqlite4n6 carve` CLI writes a **combined review workbook** (`evi
4040
| Code | Severity | Observes |
4141
|---|:-:|---|
4242
| `SQLITE-DELETED-RECORD-RECOVERED` | Medium | A record-shaped cell recovered from unallocated space. |
43+
| `SQLITE-DROPPED-SCHEMA-RECOVERED` | Medium | A deleted `sqlite_master` row recovered from page-1 free space — a dropped/replaced table/index/view/trigger and its `CREATE` statement. |
4344
| `SQLITE-FREELIST-NONEMPTY` | Low | Free pages present — consistent with prior deletions. |
4445
| `SQLITE-WAL-UNCHECKPOINTED` | Medium | `-wal` overlay the main file does not reflect. |
4546
| `SQLITE-PAGECOUNT-MISMATCH` | High | Header page count disagrees with file length. |

forensic/src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ pub enum AnomalyKind {
5858
/// Recovered rowid.
5959
rowid: i64,
6060
},
61+
/// A `sqlite_master` row was recovered from page-1 free space whose
62+
/// definition is absent from the live schema — consistent with a **dropped
63+
/// (or replaced)** table/index/view/trigger whose `CREATE` statement and
64+
/// existence survive the drop under `secure_delete=OFF`.
65+
DroppedSchemaRecovered {
66+
/// `sqlite_master.type` — `table`, `index`, `view`, or `trigger`.
67+
object_type: String,
68+
/// Name of the dropped object.
69+
name: String,
70+
},
6171
/// The freelist is non-empty: the database holds free (unallocated) pages.
6272
/// Consistent with prior deletions (`DELETE` without `VACUUM`); those pages
6373
/// may retain recoverable deleted records.
@@ -147,6 +157,7 @@ impl AnomalyKind {
147157
Severity::Low
148158
}
149159
AnomalyKind::DeletedRecordRecovered { .. }
160+
| AnomalyKind::DroppedSchemaRecovered { .. }
150161
| AnomalyKind::WalUncheckpointedState { .. }
151162
| AnomalyKind::JournalRecoverable { .. }
152163
| AnomalyKind::JournalSchemaChange { .. }
@@ -164,6 +175,7 @@ impl AnomalyKind {
164175
match self {
165176
AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
166177
AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
178+
AnomalyKind::DroppedSchemaRecovered { .. } => "SQLITE-DROPPED-SCHEMA-RECOVERED",
167179
AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
168180
AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
169181
AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
@@ -194,6 +206,11 @@ impl AnomalyKind {
194206
space at page {page} offset {offset} — consistent with a deleted \
195207
row not yet overwritten"
196208
),
209+
AnomalyKind::DroppedSchemaRecovered { object_type, name } => format!(
210+
"recovered a deleted sqlite_master row for {object_type} \"{name}\" \
211+
from page-1 free space — consistent with a dropped (or replaced) \
212+
{object_type} whose definition survives the drop"
213+
),
197214
AnomalyKind::NonEmptyFreelist { free_pages } => format!(
198215
"{free_pages} free page(s) on the freelist — consistent with prior \
199216
deletions (DELETE without VACUUM); free pages may retain \
@@ -338,6 +355,7 @@ impl Observation for Anomaly {
338355
// so are a recoverable PERSIST journal and a journaled schema page
339356
// (the prior schema/rows are recoverable from the journal images).
340357
AnomalyKind::DeletedRecordRecovered { .. }
358+
| AnomalyKind::DroppedSchemaRecovered { .. }
341359
| AnomalyKind::NonEmptyFreelist { .. }
342360
| AnomalyKind::JournalRecoverable { .. }
343361
| AnomalyKind::JournalSchemaChange { .. } => Category::Residue,
@@ -380,6 +398,18 @@ impl Observation for Anomaly {
380398
location: Some(Location::ByteOffset(*offset as u64)),
381399
},
382400
],
401+
AnomalyKind::DroppedSchemaRecovered { object_type, name } => vec![
402+
Evidence {
403+
field: "object_type".to_string(),
404+
value: object_type.clone(),
405+
location: None,
406+
},
407+
Evidence {
408+
field: "name".to_string(),
409+
value: name.clone(),
410+
location: None,
411+
},
412+
],
383413
AnomalyKind::NonEmptyFreelist { free_pages } => vec![Evidence {
384414
field: "free_pages".to_string(),
385415
value: free_pages.to_string(),
@@ -1469,6 +1499,16 @@ pub fn audit(db: &Database) -> Vec<Anomaly> {
14691499
}));
14701500
}
14711501

1502+
// Dropped/replaced schema objects whose sqlite_master row survives in page-1
1503+
// free space (a DROP under secure_delete=OFF). Surfaced as findings so a
1504+
// dropped table's existence + name is reported, not just buried in the carve.
1505+
for schema in recover_dropped_schemas(db) {
1506+
out.push(Anomaly::new(AnomalyKind::DroppedSchemaRecovered {
1507+
object_type: schema.object_type,
1508+
name: schema.name,
1509+
}));
1510+
}
1511+
14721512
out
14731513
}
14741514

forensic/tests/dropped_schema.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
#![allow(clippy::unwrap_used, clippy::expect_used)]
1919

2020
use sqlite_core::Database;
21-
use sqlite_forensic::recover_dropped_schemas;
21+
use sqlite_forensic::{audit, recover_dropped_schemas, AnomalyKind};
2222

2323
fn open_fixture() -> Database {
2424
let bytes = std::fs::read("../tests/data/dropped_table_schema.db")
@@ -54,3 +54,19 @@ fn does_not_report_a_live_table_as_dropped() {
5454
"live table `keep` must NOT be reported as a recovered (dropped) schema: {schemas:?}"
5555
);
5656
}
57+
58+
#[test]
59+
fn audit_surfaces_the_dropped_schema_as_a_finding() {
60+
let db = open_fixture();
61+
let found = audit(&db).into_iter().any(|a| {
62+
matches!(
63+
a.kind,
64+
AnomalyKind::DroppedSchemaRecovered { ref name, ref object_type }
65+
if name == "secrets" && object_type == "table"
66+
)
67+
});
68+
assert!(
69+
found,
70+
"audit must surface the dropped `secrets` table as a SQLITE-DROPPED-SCHEMA-RECOVERED finding"
71+
);
72+
}

0 commit comments

Comments
 (0)