mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-21 20:18:06 +02:00
Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks
* feat: Introduce field interner and typed bounded vars for enhanced type tracking
* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking
* feat: Centralize method name extraction with bare_method_name helper
* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch
* feat: Enhance C++ taint tracking with additional container operations and inline method resolution
* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking
* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis
* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations
* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details
* test: Add comprehensive tests for lattice algebra laws and SSA edge cases
* feat: Add destructured session user handling and safe user ID access patterns
* feat: Implement row-population reverse-walk for enhanced authorization checks
* feat: Enhance authorization checks with local alias chain for self-actor types
* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction
* feat: Implement chained method call inner-gate rebinding for SSRF prevention
* feat: Add observability and error modules, enhance debug functionality, and implement theme context
* feat: Remove Auth Analysis page and update navigation to redirect to Explorer
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity
* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build
The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(closure-capture): flip JS/TS fixtures to required-finding
The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.
Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".
Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis
* feat: Introduce health module and enhance health score computation with calibration tests
* feat: Add expectations configuration and cleanup .gitignore for log files
* feat: Implement theme selection and enhance settings panel for triage sync
* feat: Suppress false positives for strcpy calls with literal sources in AST
* feat: Update analyse_function_ssa to return body CFG for accurate analysis
* feat: Add bug report and feature request templates for improved issue tracking
* feat: removed dev scripts
* feat: update README.md for clarity and consistency in fixture descriptions
* feat: removed dev docs
* feat: clean up error handling and UI elements for improved user experience
* feat: adjust button sizes in HeaderBar for better UI consistency
* feat: enhance taint analysis with additional context for sanitizer and taint findings
* cargo fmt
* prettier
* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts
* feat: add script to frame PNG screenshots with brand gradient
* feat: add fuzzing support with new targets and CI workflows
* refactor: streamline match expressions and improve formatting in CLI and output handling
* feat: enhance configuration display with detailed output options
* feat: stage demo configuration for improved CLI screenshot output
* feat: expose merge_configs function for user-configurable settings
* refactor: simplify code structure and improve readability in config handling
* refactor: improve descriptions for vulnerability patterns in various languages
* feat: update MIT License section with additional usage details and copyright information
* feat: update screenshots
* refactor: update build process and paths for frontend assets
* feat: add cross-file taint fuzzing target and supporting dictionary
* refactor: clean up formatting and comments in fuzz configuration and example files
* refactor: remove outdated comments and clean up CI configuration files
* chore: update changelog dates and improve formatting in documentation
* refactor: update Cargo.toml and CI configuration for improved packaging and build process
* refactor: enhance quote-stripping logic to prevent panics and add regression tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
507
src/database.rs
507
src/database.rs
|
|
@ -98,7 +98,7 @@ pub mod index {
|
|||
container TEXT NOT NULL DEFAULT '',
|
||||
disambig INTEGER,
|
||||
kind TEXT NOT NULL DEFAULT 'fn',
|
||||
body TEXT NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(project, file_path, name, container, arity, disambig, kind)
|
||||
);
|
||||
|
|
@ -173,6 +173,26 @@ pub mod index {
|
|||
created_at TEXT NOT NULL,
|
||||
UNIQUE(suppress_by, match_value)
|
||||
);
|
||||
|
||||
-- First time we observed each finding fingerprint. Lazy-populated by the
|
||||
-- overview endpoint when computing backlog age — INSERT OR IGNORE means
|
||||
-- only the earliest scan that mentioned a fingerprint sticks.
|
||||
CREATE TABLE IF NOT EXISTS finding_first_seen (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
first_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes on (project, file_path) for the per-file replace_* paths.
|
||||
-- Without these, every DELETE WHERE project=? AND file_path=? does a
|
||||
-- full table scan, which dominates indexing time as the cache grows.
|
||||
CREATE INDEX IF NOT EXISTS idx_function_summaries_project_file
|
||||
ON function_summaries(project, file_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssa_function_summaries_project_file
|
||||
ON ssa_function_summaries(project, file_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssa_function_bodies_project_file
|
||||
ON ssa_function_bodies(project, file_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_check_summaries_project_file
|
||||
ON auth_check_summaries(project, file_path);
|
||||
"#;
|
||||
|
||||
/// Engine version used to detect stale caches across upgrades.
|
||||
|
|
@ -191,7 +211,10 @@ pub mod index {
|
|||
/// byte offset to a depth-first structural index. Pre-0.5.0 caches
|
||||
/// store byte-offset disambigs and would fail to match bodies built
|
||||
/// by the new engine, so they are silently rebuilt on open.
|
||||
pub const SCHEMA_VERSION: &str = "2";
|
||||
/// * `"3"` — `ssa_function_bodies.body` changed from JSON TEXT to
|
||||
/// bincode BLOB. Old JSON payloads cannot be deserialised by the
|
||||
/// new engine, so they are silently rebuilt on open.
|
||||
pub const SCHEMA_VERSION: &str = "3";
|
||||
|
||||
// TODO: ADD CLEANS FOR EACH TABLE BASED ON PROJECT WHICH RUNS ON CLEAN
|
||||
// TODO: ADD DROP AND GIVE A CLI PARAMETER FOR DROP
|
||||
|
|
@ -263,7 +286,12 @@ pub mod index {
|
|||
| OpenFlags::SQLITE_OPEN_CREATE
|
||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX;
|
||||
let manager = SqliteConnectionManager::file(database_path).with_flags(flags);
|
||||
let pool = Arc::new(Pool::new(manager)?);
|
||||
// r2d2's default `max_size` is 10, which can stall rayon
|
||||
// workers on machines with more cores than that during the
|
||||
// parallel indexing pass. Size the pool to comfortably hold
|
||||
// a connection per rayon thread plus a small slack.
|
||||
let max_conns = (num_cpus::get() as u32 + 4).max(16);
|
||||
let pool = Arc::new(Pool::builder().max_size(max_conns).build(manager)?);
|
||||
|
||||
{
|
||||
let conn = pool.get()?;
|
||||
|
|
@ -411,13 +439,17 @@ pub mod index {
|
|||
tracing::info!(
|
||||
"db schema version changed ({old} → {current}), clearing summary caches"
|
||||
);
|
||||
// Drop ssa_function_bodies entirely: column type changed
|
||||
// to BLOB in v3 and `CREATE TABLE IF NOT EXISTS` will
|
||||
// not migrate the column on an existing table.
|
||||
conn.execute_batch(
|
||||
"DELETE FROM function_summaries;
|
||||
"DROP TABLE IF EXISTS ssa_function_bodies;
|
||||
DELETE FROM function_summaries;
|
||||
DELETE FROM ssa_function_summaries;
|
||||
DELETE FROM ssa_function_bodies;
|
||||
DELETE FROM auth_check_summaries;
|
||||
DELETE FROM files;",
|
||||
)?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nyx_metadata (key, value) VALUES ('schema_version', ?1)",
|
||||
params![current],
|
||||
|
|
@ -1005,26 +1037,32 @@ pub mod index {
|
|||
}
|
||||
}
|
||||
|
||||
/// Load symbol metadata (name, arity, lang, namespace) for a single file.
|
||||
/// Load symbol metadata (name, arity, lang, namespace, container, kind)
|
||||
/// for a single file.
|
||||
///
|
||||
/// Lighter than `load_all_ssa_summaries` — skips JSON deserialization of
|
||||
/// the full summary body and filters by file_path in the query.
|
||||
/// the full summary body and filters by file_path in the query. `kind`
|
||||
/// is the [`crate::symbol::FuncKind`] slug (`"fn"`, `"method"`,
|
||||
/// `"closure"`, ...) so consumers can distinguish anonymous functions
|
||||
/// from named ones.
|
||||
pub fn load_ssa_summaries_for_file(
|
||||
&self,
|
||||
file_path: &str,
|
||||
) -> NyxResult<Vec<(String, i64, String, String)>> {
|
||||
) -> NyxResult<Vec<(String, i64, String, String, String, String)>> {
|
||||
let mut stmt = self.c().prepare(
|
||||
"SELECT name, arity, lang, namespace
|
||||
"SELECT name, arity, lang, namespace, container, kind
|
||||
FROM ssa_function_summaries
|
||||
WHERE project = ?1 AND file_path = ?2",
|
||||
)?;
|
||||
let rows: Vec<(String, i64, String, String)> = stmt
|
||||
let rows: Vec<(String, i64, String, String, String, String)> = stmt
|
||||
.query_map(rusqlite::params![self.project, file_path], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(Result::ok)
|
||||
|
|
@ -1035,7 +1073,11 @@ pub mod index {
|
|||
/// Atomically replace all SSA callee bodies for a single file.
|
||||
///
|
||||
/// Persists cross-file callee bodies for interprocedural symex.
|
||||
/// Bodies are serialized as JSON TEXT, matching the ssa_function_summaries pattern.
|
||||
/// Bodies are serialized as MessagePack (rmp-serde, named-field
|
||||
/// encoding) BLOBs — JSON proved too costly at indexing time on
|
||||
/// large SSA structures, and bincode's positional format trips
|
||||
/// over the `#[serde(skip_serializing_if = ...)]` attributes
|
||||
/// scattered through `OptimizeResult` and friends.
|
||||
/// Input tuple: `(name, arity, lang, namespace, container, disambig, kind, body)`.
|
||||
pub fn replace_ssa_bodies_for_file(
|
||||
&mut self,
|
||||
|
|
@ -1070,7 +1112,7 @@ pub mod index {
|
|||
)?;
|
||||
|
||||
for (name, arity, lang, namespace, container, disambig, kind, body) in bodies {
|
||||
let json = serde_json::to_string(body)
|
||||
let blob = rmp_serde::to_vec_named(body)
|
||||
.map_err(|e| NyxError::Msg(format!("SSA body serialise: {e}")))?;
|
||||
let disambig_sql = disambig.map(|d| d as i64);
|
||||
stmt.execute(params![
|
||||
|
|
@ -1084,7 +1126,7 @@ pub mod index {
|
|||
container,
|
||||
disambig_sql,
|
||||
kind.as_str(),
|
||||
json,
|
||||
blob,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
|
|
@ -1128,7 +1170,7 @@ pub mod index {
|
|||
String,
|
||||
Option<i64>,
|
||||
String,
|
||||
String,
|
||||
Vec<u8>,
|
||||
)> = stmt
|
||||
.query_map([&self.project], |row| {
|
||||
Ok((
|
||||
|
|
@ -1140,7 +1182,7 @@ pub mod index {
|
|||
row.get::<_, String>(5)?,
|
||||
row.get::<_, Option<i64>>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
row.get::<_, Vec<u8>>(8)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| match r {
|
||||
|
|
@ -1157,10 +1199,10 @@ pub mod index {
|
|||
let results: Vec<_> = rows
|
||||
.par_iter()
|
||||
.filter_map(
|
||||
|(fp, name, lang, arity, ns, container, disambig, kind, json)| {
|
||||
serde_json::from_str::<crate::taint::ssa_transfer::CalleeSsaBody>(json)
|
||||
|(fp, name, lang, arity, ns, container, disambig, kind, blob)| {
|
||||
rmp_serde::from_slice::<crate::taint::ssa_transfer::CalleeSsaBody>(blob)
|
||||
.map_err(|e| {
|
||||
tracing::warn!("failed to deserialize SSA body JSON: {e}");
|
||||
tracing::warn!("failed to deserialize SSA body: {e}");
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
|
|
@ -1188,8 +1230,8 @@ pub mod index {
|
|||
Ok(results)
|
||||
} else {
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for (fp, name, lang, arity, ns, container, disambig, kind, json) in &rows {
|
||||
match serde_json::from_str::<crate::taint::ssa_transfer::CalleeSsaBody>(json) {
|
||||
for (fp, name, lang, arity, ns, container, disambig, kind, blob) in &rows {
|
||||
match rmp_serde::from_slice::<crate::taint::ssa_transfer::CalleeSsaBody>(blob) {
|
||||
Ok(mut b) => {
|
||||
// See note in parallel branch above.
|
||||
crate::taint::ssa_transfer::rebuild_body_graph(&mut b);
|
||||
|
|
@ -1206,7 +1248,7 @@ pub mod index {
|
|||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize SSA body JSON: {e}");
|
||||
tracing::warn!("failed to deserialize SSA body: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1278,6 +1320,205 @@ pub mod index {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically replace all four per-file caches in a single
|
||||
/// transaction. Equivalent in effect to calling
|
||||
/// [`Self::replace_summaries_for_file`],
|
||||
/// [`Self::replace_ssa_summaries_for_file`],
|
||||
/// [`Self::replace_ssa_bodies_for_file`] and
|
||||
/// [`Self::replace_auth_summaries_for_file`] in sequence, but
|
||||
/// issues a single fsync at commit instead of four — the
|
||||
/// dominant cost on large scans.
|
||||
///
|
||||
/// Behaviour parity with the four-call sequence:
|
||||
/// * function and auth summaries: DELETE-then-INSERT regardless
|
||||
/// of input length, so emptying a file's summaries clears
|
||||
/// stale rows.
|
||||
/// * SSA summaries and bodies: only touched when the input is
|
||||
/// non-empty, matching the existing scan path.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn replace_all_for_file(
|
||||
&mut self,
|
||||
file_path: &Path,
|
||||
file_hash: &[u8],
|
||||
func_summaries: &[crate::summary::FuncSummary],
|
||||
ssa_summaries: &[(
|
||||
String,
|
||||
usize,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<u32>,
|
||||
crate::symbol::FuncKind,
|
||||
crate::summary::ssa_summary::SsaFuncSummary,
|
||||
)],
|
||||
ssa_bodies: &[(
|
||||
String,
|
||||
usize,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<u32>,
|
||||
crate::symbol::FuncKind,
|
||||
crate::taint::ssa_transfer::CalleeSsaBody,
|
||||
)],
|
||||
auth_summaries: &[(
|
||||
String,
|
||||
usize,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<u32>,
|
||||
crate::symbol::FuncKind,
|
||||
crate::auth_analysis::model::AuthCheckSummary,
|
||||
)],
|
||||
) -> NyxResult<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
let path_str = file_path.to_string_lossy();
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
|
||||
|
||||
// function_summaries — always replace.
|
||||
tx.execute(
|
||||
"DELETE FROM function_summaries WHERE project = ?1 AND file_path = ?2",
|
||||
params![self.project, path_str],
|
||||
)?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR REPLACE INTO function_summaries
|
||||
(project, file_path, file_hash, name, arity, lang,
|
||||
container, disambig, kind, summary, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
)?;
|
||||
for s in func_summaries {
|
||||
let json = serde_json::to_string(s)
|
||||
.map_err(|e| NyxError::Msg(format!("summary serialise: {e}")))?;
|
||||
let disambig_sql = s.disambig.map(|d| d as i64);
|
||||
stmt.execute(params![
|
||||
self.project,
|
||||
path_str,
|
||||
file_hash,
|
||||
s.name,
|
||||
s.param_count as i64,
|
||||
s.lang,
|
||||
s.container,
|
||||
disambig_sql,
|
||||
s.kind.as_str(),
|
||||
json,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
// ssa_function_summaries — only touched when non-empty.
|
||||
if !ssa_summaries.is_empty() {
|
||||
tx.execute(
|
||||
"DELETE FROM ssa_function_summaries
|
||||
WHERE project = ?1 AND file_path = ?2",
|
||||
params![self.project, path_str],
|
||||
)?;
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR REPLACE INTO ssa_function_summaries
|
||||
(project, file_path, file_hash, name, arity, lang, namespace,
|
||||
container, disambig, kind, summary, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)?;
|
||||
for (name, arity, lang, namespace, container, disambig, kind, summary) in
|
||||
ssa_summaries
|
||||
{
|
||||
let json = serde_json::to_string(summary)
|
||||
.map_err(|e| NyxError::Msg(format!("SSA summary serialise: {e}")))?;
|
||||
let disambig_sql = disambig.map(|d| d as i64);
|
||||
stmt.execute(params![
|
||||
self.project,
|
||||
path_str,
|
||||
file_hash,
|
||||
name,
|
||||
*arity as i64,
|
||||
lang,
|
||||
namespace,
|
||||
container,
|
||||
disambig_sql,
|
||||
kind.as_str(),
|
||||
json,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
// ssa_function_bodies — only touched when non-empty.
|
||||
if !ssa_bodies.is_empty() {
|
||||
tx.execute(
|
||||
"DELETE FROM ssa_function_bodies
|
||||
WHERE project = ?1 AND file_path = ?2",
|
||||
params![self.project, path_str],
|
||||
)?;
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR REPLACE INTO ssa_function_bodies
|
||||
(project, file_path, file_hash, name, arity, lang, namespace,
|
||||
container, disambig, kind, body, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)?;
|
||||
for (name, arity, lang, namespace, container, disambig, kind, body) in ssa_bodies {
|
||||
let blob = rmp_serde::to_vec_named(body)
|
||||
.map_err(|e| NyxError::Msg(format!("SSA body serialise: {e}")))?;
|
||||
let disambig_sql = disambig.map(|d| d as i64);
|
||||
stmt.execute(params![
|
||||
self.project,
|
||||
path_str,
|
||||
file_hash,
|
||||
name,
|
||||
*arity as i64,
|
||||
lang,
|
||||
namespace,
|
||||
container,
|
||||
disambig_sql,
|
||||
kind.as_str(),
|
||||
blob,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
// auth_check_summaries — always replace, even when empty,
|
||||
// so a helper that lost its ownership check no longer
|
||||
// leaks lifts into subsequent pass-2 runs.
|
||||
tx.execute(
|
||||
"DELETE FROM auth_check_summaries WHERE project = ?1 AND file_path = ?2",
|
||||
params![self.project, path_str],
|
||||
)?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR REPLACE INTO auth_check_summaries
|
||||
(project, file_path, file_hash, name, arity, lang, namespace,
|
||||
container, disambig, kind, summary, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)?;
|
||||
for (name, arity, lang, namespace, container, disambig, kind, summary) in
|
||||
auth_summaries
|
||||
{
|
||||
let json = serde_json::to_string(summary)
|
||||
.map_err(|e| NyxError::Msg(format!("auth summary serialise: {e}")))?;
|
||||
let disambig_sql = disambig.map(|d| d as i64);
|
||||
stmt.execute(params![
|
||||
self.project,
|
||||
path_str,
|
||||
file_hash,
|
||||
name,
|
||||
*arity as i64,
|
||||
lang,
|
||||
namespace,
|
||||
container,
|
||||
disambig_sql,
|
||||
kind.as_str(),
|
||||
json,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load every `AuthCheckSummary` for this project.
|
||||
///
|
||||
/// Returns rows with full metadata for `FuncKey` reconstruction:
|
||||
|
|
@ -1962,6 +2203,103 @@ pub mod index {
|
|||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Record the first time a finding fingerprint was observed. Idempotent —
|
||||
/// the earliest call wins via INSERT OR IGNORE. Used by the overview
|
||||
/// backlog-age computation; ts should be the originating scan's
|
||||
/// `started_at` (RFC-3339).
|
||||
pub fn record_finding_first_seen(&self, fingerprint: &str, ts: &str) -> NyxResult<()> {
|
||||
self.c().execute(
|
||||
"INSERT OR IGNORE INTO finding_first_seen (fingerprint, first_seen_at) VALUES (?1, ?2)",
|
||||
params![fingerprint, ts],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk variant. Inserts ignoring conflicts.
|
||||
pub fn record_finding_first_seen_bulk(
|
||||
&self,
|
||||
entries: &[(String, String)],
|
||||
) -> NyxResult<()> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.c();
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR IGNORE INTO finding_first_seen (fingerprint, first_seen_at) VALUES (?1, ?2)",
|
||||
)?;
|
||||
for (fp, ts) in entries {
|
||||
stmt.execute(params![fp, ts])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up first-seen timestamps for a set of fingerprints. Missing
|
||||
/// entries are simply absent from the returned map.
|
||||
pub fn get_first_seen_map(
|
||||
&self,
|
||||
fingerprints: &[String],
|
||||
) -> NyxResult<std::collections::HashMap<String, String>> {
|
||||
if fingerprints.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
// SQLite IN-clause cap is high but parameter count is bounded — chunk
|
||||
// for safety with large fingerprint sets.
|
||||
let mut out = std::collections::HashMap::with_capacity(fingerprints.len());
|
||||
let conn = self.c();
|
||||
for chunk in fingerprints.chunks(500) {
|
||||
let placeholders = (1..=chunk.len())
|
||||
.map(|i| format!("?{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT fingerprint, first_seen_at FROM finding_first_seen WHERE fingerprint IN ({placeholders})"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let params: Vec<&dyn rusqlite::ToSql> =
|
||||
chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||
let rows = stmt.query_map(params.as_slice(), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
for r in rows.flatten() {
|
||||
out.insert(r.0, r.1);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Get a single metadata value by key. Returns None if absent.
|
||||
pub fn get_metadata(&self, key: &str) -> NyxResult<Option<String>> {
|
||||
let conn = self.c();
|
||||
let mut stmt = conn.prepare("SELECT value FROM nyx_metadata WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(row.get(0)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a metadata value (insert-or-replace).
|
||||
pub fn set_metadata(&self, key: &str, value: &str) -> NyxResult<()> {
|
||||
self.c().execute(
|
||||
"INSERT OR REPLACE INTO nyx_metadata (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a metadata key. Returns true if a row was deleted.
|
||||
pub fn delete_metadata(&self, key: &str) -> NyxResult<bool> {
|
||||
let n = self
|
||||
.c()
|
||||
.execute("DELETE FROM nyx_metadata WHERE key = ?1", params![key])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Delete a suppression rule by ID. Returns true if a row was deleted.
|
||||
pub fn delete_suppression_rule(&self, id: i64) -> NyxResult<bool> {
|
||||
let count = self.c().execute(
|
||||
|
|
@ -2175,7 +2513,9 @@ fn ssa_summaries_round_trip() {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
@ -2207,7 +2547,9 @@ fn ssa_summaries_round_trip() {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
},
|
||||
),
|
||||
];
|
||||
|
|
@ -2377,7 +2719,9 @@ fn ssa_summaries_hash_rescan_replaces_stale() {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
},
|
||||
)];
|
||||
idx.replace_ssa_summaries_for_file(&f, &hash_v1, &sums_v1)
|
||||
|
|
@ -2411,7 +2755,9 @@ fn ssa_summaries_hash_rescan_replaces_stale() {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
},
|
||||
)];
|
||||
idx.replace_ssa_summaries_for_file(&f, &hash_v2, &sums_v2)
|
||||
|
|
@ -2466,7 +2812,9 @@ fn clear_drops_ssa_summaries_table() {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
},
|
||||
)];
|
||||
idx.replace_ssa_summaries_for_file(&f, &hash, &sums)
|
||||
|
|
@ -2521,6 +2869,8 @@ fn make_test_callee_body(
|
|||
value_defs,
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: vec![],
|
||||
field_interner: crate::ssa::ir::FieldInterner::new(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
},
|
||||
opt: crate::ssa::OptimizeResult {
|
||||
const_values: std::collections::HashMap::new(),
|
||||
|
|
@ -2733,7 +3083,9 @@ fn make_test_ssa_summary() -> crate::summary::ssa_summary::SsaFuncSummary {
|
|||
abstract_transfer: vec![],
|
||||
param_return_paths: vec![],
|
||||
points_to: Default::default(),
|
||||
field_points_to: Default::default(),
|
||||
return_path_facts: smallvec::SmallVec::new(),
|
||||
typed_call_receivers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3382,3 +3734,116 @@ fn metadata_table_survives_clear() {
|
|||
let stored = index::Indexer::get_stored_engine_version(&pool).unwrap();
|
||||
assert_eq!(stored.as_deref(), Some(index::ENGINE_VERSION));
|
||||
}
|
||||
|
||||
/// Pointer-Phase 5 / A3 audit: field_points_to round-trips through
|
||||
/// the SsaFuncSummary SQLite blob. Pin that the new field_points_to
|
||||
/// records preserve param_field_reads, param_field_writes, the
|
||||
/// receiver sentinel (`u32::MAX`), the container-element marker
|
||||
/// (`<elem>`), and the `overflow` flag across serialise → store →
|
||||
/// load → deserialise. This is the strict-additive contract for
|
||||
/// pre-Phase-5 blobs (default-empty deserialises cleanly) and the
|
||||
/// completeness check for the W3 cross-call resolver.
|
||||
#[test]
|
||||
fn ssa_summaries_round_trip_preserves_field_points_to() {
|
||||
use crate::summary::points_to::FieldPointsToSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let db = td.path().join("nyx.sqlite");
|
||||
let f = td.path().join("store.rs");
|
||||
std::fs::write(&f, "// helper that writes obj.cache").unwrap();
|
||||
|
||||
let pool = index::Indexer::init(&db).unwrap();
|
||||
let mut idx = index::Indexer::from_pool("proj", &pool).unwrap();
|
||||
|
||||
let hash = index::Indexer::digest_bytes(b"// helper that writes obj.cache");
|
||||
|
||||
// Build a summary with one read on param 0 ("name"), one write on
|
||||
// param 1 ("cache"), one read on the receiver sentinel ("kind"),
|
||||
// and an ELEM marker on param 0. Round-trip must preserve all
|
||||
// four channels.
|
||||
let mut fpt = FieldPointsToSummary::empty();
|
||||
fpt.add_read(0, "name");
|
||||
fpt.add_write(1, "cache");
|
||||
fpt.add_read(u32::MAX, "kind");
|
||||
fpt.add_write(0, "<elem>");
|
||||
|
||||
let summary = SsaFuncSummary {
|
||||
field_points_to: fpt.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let row = (
|
||||
"store".to_string(),
|
||||
2_usize,
|
||||
"rust".to_string(),
|
||||
"store.rs".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
crate::symbol::FuncKind::Function,
|
||||
summary,
|
||||
);
|
||||
idx.replace_ssa_summaries_for_file(&f, &hash, &[row])
|
||||
.unwrap();
|
||||
|
||||
let loaded = idx.load_all_ssa_summaries().unwrap();
|
||||
assert_eq!(loaded.len(), 1, "single summary stored, single returned");
|
||||
let (_, name, _, _, _, _, _, _, sum) = &loaded[0];
|
||||
assert_eq!(name, "store");
|
||||
assert_eq!(
|
||||
sum.field_points_to, fpt,
|
||||
"field_points_to must round-trip byte-equal",
|
||||
);
|
||||
|
||||
// Spot-check sentinel + ELEM marker channels.
|
||||
let recv_read = sum
|
||||
.field_points_to
|
||||
.param_field_reads
|
||||
.iter()
|
||||
.find(|(p, _)| *p == u32::MAX)
|
||||
.expect("receiver read at u32::MAX sentinel");
|
||||
assert!(recv_read.1.iter().any(|s| s == "kind"));
|
||||
|
||||
let elem_write = sum
|
||||
.field_points_to
|
||||
.param_field_writes
|
||||
.iter()
|
||||
.find(|(p, _)| *p == 0)
|
||||
.expect("param 0 writes recorded");
|
||||
assert!(
|
||||
elem_write.1.iter().any(|s| s == "<elem>"),
|
||||
"<elem> marker must survive round-trip without conversion",
|
||||
);
|
||||
assert!(!sum.field_points_to.overflow);
|
||||
}
|
||||
|
||||
/// Pre-Phase-5 blob compatibility: a summary serialised without
|
||||
/// `field_points_to` deserialises with the empty default — no
|
||||
/// migration needed because the field is `#[serde(default)]`.
|
||||
#[test]
|
||||
fn ssa_summaries_pre_phase5_blob_decodes_with_empty_field_points_to() {
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
|
||||
// Hand-craft JSON without the `field_points_to` key.
|
||||
let pre_phase5_json = r#"{
|
||||
"param_to_return": [],
|
||||
"param_to_sink": [],
|
||||
"source_caps": 0,
|
||||
"param_to_sink_param": [],
|
||||
"param_container_to_return": [],
|
||||
"param_to_container_store": [],
|
||||
"return_type": null,
|
||||
"return_abstract": null,
|
||||
"source_to_callback": [],
|
||||
"receiver_to_return": null,
|
||||
"receiver_to_sink": 0,
|
||||
"abstract_transfer": [],
|
||||
"param_return_paths": [],
|
||||
"return_path_facts": [],
|
||||
"typed_call_receivers": []
|
||||
}"#;
|
||||
let sum: SsaFuncSummary = serde_json::from_str(pre_phase5_json).unwrap();
|
||||
assert!(
|
||||
sum.field_points_to.is_empty(),
|
||||
"missing field_points_to must default to empty",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue