mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-15 21:11:02 +02:00
Python fp and docs updtes (#58)
* refactor: Update comments for clarity and add expectations.json files for performance metrics * feat: Implement FP guard for JS/TS local-collection receivers to suppress missing ownership checks * feat: Enhance Rust parameter handling to classify local collections and prevent false ownership checks * refactor: Simplify code formatting for better readability in multiple files * refactor: Improve UTF-8 sequence length handling and enhance clarity in loop iteration * feat: Update Java and Python patterns to include new security rules * refactor: Improve comment clarity and consistency across multiple Rust files * refactor: Simplify code formatting for improved readability in integration tests and module files * refactor: Improve comment formatting and enhance clarity in assertions across multiple files
This commit is contained in:
parent
4db0805de6
commit
a438886217
291 changed files with 9485 additions and 3851 deletions
2
tests/fixtures/async_rust/main.rs
vendored
2
tests/fixtures/async_rust/main.rs
vendored
|
|
@ -1,7 +1,7 @@
|
|||
// Regression fixture: Rust async flow through `tokio::process::Command`.
|
||||
//
|
||||
// Per docs/language-maturity.md, Rust's Tokio process variants are not
|
||||
// yet covered — the Tokio async process APIs are a known gap. The
|
||||
// yet covered, the Tokio async process APIs are a known gap. The
|
||||
// fixture is checked in so that when Rust async-process coverage lands,
|
||||
// the engine begins producing the intended finding and the
|
||||
// `forbidden_findings` assertion forces whoever adds the coverage to
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Target: authorization happens inside `require_owner`, which
|
||||
// delegates to `require_group_member` (a configured authorization
|
||||
// check name). The handler in `cross_file_helper_handler.rs`
|
||||
// delegates ownership validation to this helper — cross-file helper
|
||||
// delegates ownership validation to this helper, cross-file helper
|
||||
// lifting should recognise the call as an auth check covering the
|
||||
// supplied `row`.
|
||||
struct Db;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// produces a `DatabaseConnection` via SSA `constructor_type` (through
|
||||
// `peel_identity_suffix`, which strips `.unwrap()` before matching). The
|
||||
// handler then calls `conn.execute(..)`, a callee name that appears in
|
||||
// neither `mutation_indicator_names` nor `read_indicator_names` for Rust —
|
||||
// neither `mutation_indicator_names` nor `read_indicator_names` for Rust ,
|
||||
// name-based classification returns `None`, so the ownership gate
|
||||
// already cannot flag the call. The type-map refinement should *still*
|
||||
// leave the call unflagged (the type map produces `DbMutation`, but
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub async fn handle_list_peer_docs(req: Req, ctx: Ctx) -> Result<String, ()> {
|
|||
let user = auth::require_auth(&req, &ctx).await?;
|
||||
let doc_ids: Vec<i64> = vec![1, 2, 3];
|
||||
|
||||
// Pure in-memory bookkeeping — no authorization decision here.
|
||||
// Pure in-memory bookkeeping, no authorization decision here.
|
||||
let mut counts: HashMap<i64, usize> = HashMap::new();
|
||||
let mut seen: HashSet<i64> = HashSet::new();
|
||||
for doc_id in &doc_ids {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// B4 regression guard: `format_target` does NOT auth-check
|
||||
// `group_id` — it just constructs a string from it. The helper-lift
|
||||
// `group_id`, it just constructs a string from it. The helper-lift
|
||||
// pass must not synthesise a covering AuthCheck on the handler's call
|
||||
// site, so the subsequent `db.exec("INSERT INTO comments …", &[group_id])`
|
||||
// MUST still flag.
|
||||
|
|
@ -19,7 +19,7 @@ mod auth {
|
|||
}
|
||||
|
||||
fn format_target(group_id: i64, suffix: &str) -> String {
|
||||
// No auth check here — pure formatting.
|
||||
// No auth check here, pure formatting.
|
||||
format!("group:{}{}", group_id, suffix)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ pub async fn handle_delete_doc(req: Req, ctx: Ctx, doc_id: i64) -> Result<String
|
|||
return json_err("cannot delete another user's doc", 403);
|
||||
}
|
||||
|
||||
// By construction, the row belongs to `user` — so any id read from it is authorized.
|
||||
// By construction, the row belongs to `user`, so any id read from it is authorized.
|
||||
let group_id = existing.get_i64("group_id");
|
||||
realtime::publish_to_group(group_id, "doc_deleted");
|
||||
Ok("ok".into())
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ pub async fn handle_update_doc(req: Req, ctx: Ctx, doc_id: i64) -> Result<String
|
|||
);
|
||||
let owner_id = existing.get_i64("user_id");
|
||||
|
||||
// Equality compared but no early exit — the check has no effect.
|
||||
// Equality compared but no early exit, the check has no effect.
|
||||
if owner_id != user.id {
|
||||
// missing return
|
||||
println!("not your doc (but proceeding anyway)");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ mod auth { pub async fn require_auth(_r: &super::Req, _c: &super::Ctx) -> Result
|
|||
|
||||
// The handler's `get_peer_ids(&db, user.id)` call below must not be
|
||||
// flagged. `user` is bound from `auth::require_auth(..)` so `user.id`
|
||||
// is the caller's own id — the call is self-referential, not a foreign
|
||||
// is the caller's own id, the call is self-referential, not a foreign
|
||||
// scoped id. The library-style helper below is a pass-through so its
|
||||
// body contains no DB sinks (the internal `user_id` → DB flow is a
|
||||
// separate pattern covered by helper-summary lifting).
|
||||
|
|
|
|||
2
tests/fixtures/auth_analysis/sql_join_acl.rs
vendored
2
tests/fixtures/auth_analysis/sql_join_acl.rs
vendored
|
|
@ -2,7 +2,7 @@
|
|||
// against an ACL table (`group_members`) with a WHERE clause that pins
|
||||
// the row to the current user (`gm.user_id = ?1` bound to `user.id`).
|
||||
// Every returned row is membership-gated by construction, so downstream
|
||||
// uses of the row's columns (`group_id` here) are authorized — the
|
||||
// uses of the row's columns (`group_id` here) are authorized, the
|
||||
// `realtime::publish_to_group` call MUST NOT be flagged as missing an
|
||||
// ownership check after B3.
|
||||
struct Ctx;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// B3 regression guard: the SELECT JOINs through `audit_log` (NOT in
|
||||
// the configured ACL list) and the WHERE clause pins on
|
||||
// `al.user_id = ?1`. The audit-log row's user is the audit subject,
|
||||
// not the doc owner — so this query does NOT prove caller ownership
|
||||
// not the doc owner, so this query does NOT prove caller ownership
|
||||
// of the returned `doc_id`. The downstream realtime publish MUST
|
||||
// still flag for a missing ownership check after B3.
|
||||
struct Ctx;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// target: authorization happens inside `validate_target`, which
|
||||
// internally calls `authz::require_membership` against the same
|
||||
// `group_id` the handler subsequently mutates. The current rule cannot
|
||||
// see this transitively — B4 lifts per-function auth-check summaries
|
||||
// see this transitively, B4 lifts per-function auth-check summaries
|
||||
// (which positional params are auth-checked) so the handler-level call
|
||||
// to `validate_target(&db, group_id, user.id)` is recognised as an
|
||||
// auth check covering `group_id`. Result: `db.exec(..)` MUST NOT flag
|
||||
|
|
@ -45,7 +45,7 @@ pub async fn handle_create_comment(
|
|||
let user = auth::require_auth(&req, &ctx).await?;
|
||||
let db = Db;
|
||||
|
||||
// Authorization happens inside validate_target — helper-summary
|
||||
// Authorization happens inside validate_target, helper-summary
|
||||
// lifting propagates the per-param auth check so this covers
|
||||
// `group_id`.
|
||||
validate_target(&db, group_id, user.id).await?;
|
||||
|
|
|
|||
31
tests/fixtures/fp_guards/auth_local_collection_receiver/App.ts
vendored
Normal file
31
tests/fixtures/fp_guards/auth_local_collection_receiver/App.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// FP guard for `js.auth.missing_ownership_check` — JS built-in
|
||||
// container receivers must not be classified as data-layer sinks.
|
||||
// See `tests/benchmark/corpus/typescript/auth/safe_local_collection_receiver.ts`
|
||||
// for the full real-repo distillation.
|
||||
|
||||
type ElementsMap = Map<string, { id: string }>;
|
||||
|
||||
function fromAlias(elementsMap: ElementsMap, id: string) {
|
||||
return elementsMap.get(id);
|
||||
}
|
||||
|
||||
function fromDirectGeneric(m: Map<string, string>, k: string) {
|
||||
return m.get(k);
|
||||
}
|
||||
|
||||
function fromArrayShorthand(arr: { id: string }[], targetId: string) {
|
||||
return arr.find((x) => x.id === targetId);
|
||||
}
|
||||
|
||||
function fromLocalConstructor() {
|
||||
const cache = new Map<string, string>();
|
||||
cache.set("a", "x");
|
||||
return cache.get("a");
|
||||
}
|
||||
|
||||
function fromSet(visited: Set<string>, k: string) {
|
||||
if (!visited.has(k)) {
|
||||
visited.add(k);
|
||||
}
|
||||
return visited.size;
|
||||
}
|
||||
16
tests/fixtures/fp_guards/auth_local_collection_receiver/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/auth_local_collection_receiver/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "js.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 1,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
16
tests/fixtures/fp_guards/auth_rust_param_typed_local_collection/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/auth_rust_param_typed_local_collection/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "rs.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
93
tests/fixtures/fp_guards/auth_rust_param_typed_local_collection/snapshot.rs
vendored
Normal file
93
tests/fixtures/fp_guards/auth_rust_param_typed_local_collection/snapshot.rs
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Real-repo precision guard mirroring meilisearch's index-scheduler
|
||||
// shape:
|
||||
// crates/index-scheduler/src/scheduler/process_snapshot_creation.rs::remove_tasks
|
||||
// (`unsafe fn remove_tasks(tasks: &[Task], dst: &std::path::Path,
|
||||
// index_base_map_size: usize)` plus per-loop bitmap mutations on
|
||||
// destructured heed `Database` handles), plus the LocalCollection
|
||||
// receiver-type cluster
|
||||
// (`crates/index-scheduler/src/scheduler/enterprise_edition/network.rs::balance_shards`,
|
||||
// `unsharded: RoaringBitmap`).
|
||||
//
|
||||
// Both engine fixes must hold: the Rust `parameter` arm in
|
||||
// `collect_param_names` (only descends into `pattern`, never `type`)
|
||||
// and the Rust LocalCollection type-text classifier
|
||||
// (`rust_type_to_local_collection`). Without either, this file would
|
||||
// produce missing-ownership-check findings on internal helpers /
|
||||
// in-memory bitmap mutations.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
|
||||
struct RoaringBitmap;
|
||||
impl RoaringBitmap {
|
||||
fn new() -> Self { Self }
|
||||
fn insert(&mut self, _x: u32) -> bool { true }
|
||||
fn remove(&mut self, _x: u32) -> bool { true }
|
||||
fn contains(&self, _x: u32) -> bool { true }
|
||||
}
|
||||
|
||||
struct Task { uid: u32 }
|
||||
|
||||
struct Database;
|
||||
impl Database {
|
||||
fn delete(&self, _w: &mut u32, _u: &u32) -> Result<(), ()> { Ok(()) }
|
||||
}
|
||||
|
||||
struct TaskQueue {
|
||||
all_tasks: Database,
|
||||
canceled_by: Database,
|
||||
}
|
||||
|
||||
// Rust `parameter` arm: type-segment idents (`std`, `path`, `Path`)
|
||||
// must NOT pollute `unit.params` and gate user-input-evidence open.
|
||||
unsafe fn remove_tasks(
|
||||
tasks: &[Task],
|
||||
dst: &std::path::Path,
|
||||
sz: usize,
|
||||
) -> Result<(), ()> {
|
||||
let _ = (dst, sz);
|
||||
let mut wtxn = 0u32;
|
||||
let task_queue = TaskQueue {
|
||||
all_tasks: Database,
|
||||
canceled_by: Database,
|
||||
};
|
||||
let TaskQueue { all_tasks, canceled_by } = task_queue;
|
||||
for task in tasks {
|
||||
all_tasks.delete(&mut wtxn, &task.uid)?;
|
||||
canceled_by.delete(&mut wtxn, &task.uid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// LocalCollection typed param: `unsharded: RoaringBitmap` resolves to
|
||||
// `TypeKind::LocalCollection`, so `unsharded.insert(docid)` /
|
||||
// `unsharded.remove(docid)` classify as `SinkClass::InMemoryLocal`
|
||||
// (non-auth-relevant).
|
||||
fn balance_shards(mut unsharded: RoaringBitmap, docid: u32) {
|
||||
unsharded.insert(docid);
|
||||
unsharded.remove(docid);
|
||||
}
|
||||
|
||||
// `&'a mut HashMap<...>` reference + lifetime: ref-stripping must
|
||||
// reach the type head.
|
||||
fn store_shard_docids<'a>(
|
||||
new_shard_docids: &'a mut HashMap<String, u32>,
|
||||
shard: String,
|
||||
docid: u32,
|
||||
) {
|
||||
new_shard_docids.insert(shard, docid);
|
||||
}
|
||||
|
||||
fn add_user_id(ids: &mut HashSet<u64>, user_id: u64) {
|
||||
ids.insert(user_id);
|
||||
ids.remove(&user_id);
|
||||
}
|
||||
|
||||
fn collect_seen(seen: &mut BTreeSet<u32>, item_id: u32) {
|
||||
seen.insert(item_id);
|
||||
}
|
||||
|
||||
fn build_local_set(task_id: u32) -> RoaringBitmap {
|
||||
let mut s = RoaringBitmap::new();
|
||||
s.insert(task_id);
|
||||
s
|
||||
}
|
||||
41
tests/fixtures/fp_guards/cfg_utf8_long_condition/App.js
vendored
Normal file
41
tests/fixtures/fp_guards/cfg_utf8_long_condition/App.js
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// FP guard / panic guard — CFG condition-text truncation must be UTF-8 safe.
|
||||
//
|
||||
// Reproduces the gogs scan crash where a CodeMirror Gherkin tokenizer ships a
|
||||
// long localised regex inside a boolean sub-condition (`stream.match(/.../) &&
|
||||
// other`). When `push_condition_node` textualises the sub-expression, the
|
||||
// regex literal exceeds MAX_CONDITION_TEXT_LEN (256 bytes); naive byte-slice
|
||||
// truncation panicked when byte 256 landed inside a multi-byte UTF-8
|
||||
// character (here Gurmukhi `ਖ`, three bytes). Engine fix in
|
||||
// `src/utils/snippet.rs::truncate_at_char_boundary`, applied at three CFG
|
||||
// sites + two symex display sites.
|
||||
//
|
||||
// Invariant: scanning this file must terminate without panicking the rayon
|
||||
// worker, regardless of where byte 256 lands.
|
||||
|
||||
function tokenLocalisedFeatureKeyword(stream, state) {
|
||||
if (
|
||||
!state.inKeywordLine &&
|
||||
state.allowFeature &&
|
||||
stream.match(/(機能|功能|フィーチャ|기능|โครงหลัก|ความสามารถ|ความต้องการทางธุรกิจ|ಹೆಚ್ಚಳ|గుణము|ಮುಹಾಂದರಾ|ਮੁਹਾਂਦਰਾ|ਨਕਸ਼ ਨੁਹਾਰ|ਖਾਸੀਅਤ|रूप लेख|وِیژگی|خاصية|תכונה|Функціонал|Функция|Функционалност|Функционал|Үзенчәлеклелек|Свойство|Особина|Мөмкинлек|Могућност|Λειτουργία|Δυνατότητα|Właściwość|Vlastnosť|Trajto|Tính năng|Savybė|Požiadavka|Požadavek|Potrzeba biznesowa|Özellik|Osobina|Ominaisuus|Omadus|Mogućnost|Mogucnost|Jellemző|Funzionalità|Funktionalitéit|Funktionalität|Funkcja|Funkcionalnost|Funkcionalitāte|Funkcia|Fungsi|Functionaliteit|Funcționalitate|Funcţionalitate|Functionalitate|Funcionalitat|Funcionalidade|Fonctionnalité|Fitur|Fīča|Feature|Eiginleiki|Egenskap|Egenskab|Característica|Caracteristica|Business Need|Aspekt|Arwedd|Ability):/)
|
||||
) {
|
||||
state.inKeywordLine = true;
|
||||
return "keyword";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanity: also exercise the let-match-guard truncation site
|
||||
// (`emit_rust_match_guard_if`) by way of a JS analogue with a CFG-relevant
|
||||
// boolean chain that wraps localised text into the second branch. The CFG
|
||||
// builder still has to textualise both arms.
|
||||
function classify(s) {
|
||||
if (
|
||||
s.length > 0 &&
|
||||
s.indexOf("ਨਕਸ਼ ਨੁਹਾਰ ਖਾਸੀਅਤ रूप लेख وِیژگی خاصية תכונה Функціонал Функция Функционалност Функционал Үзенчәлеклелек Свойство Особина Мөмкинлек Могућност Λειτουργία Δυνατότητα") >= 0
|
||||
) {
|
||||
return "localised";
|
||||
}
|
||||
return "ascii";
|
||||
}
|
||||
|
||||
module.exports = { tokenLocalisedFeatureKeyword, classify };
|
||||
14
tests/fixtures/fp_guards/cfg_utf8_long_condition/expectations.json
vendored
Normal file
14
tests/fixtures/fp_guards/cfg_utf8_long_condition/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 0,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
51
tests/fixtures/fp_guards/framework_fastapi_route_level_auth/App.py
vendored
Normal file
51
tests/fixtures/fp_guards/framework_fastapi_route_level_auth/App.py
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
FP guard for FastAPI / Flask route-level dependency-injection auth.
|
||||
|
||||
The `dependencies=[Depends(requires_access_dag(...))]` decorator
|
||||
authorises the entire handler — every value the handler receives,
|
||||
every row it fetches, and every operation downstream. The
|
||||
`is_route_level` flag on the injected AuthCheck tells
|
||||
`auth_check_covers_subject` to short-circuit `true`, suppressing
|
||||
`py.auth.missing_ownership_check` on the body's ORM calls (`filter_by`,
|
||||
`scalar`, …) and on row-variable receivers (`dag.cleanup_runs(...)`).
|
||||
|
||||
A bare route with no `dependencies=` keyword is a real ownership-
|
||||
check FP — the engine must still flag it. The vulnerable
|
||||
counterpart lives in
|
||||
`tests/benchmark/corpus/python/auth/vuln_fastapi_route_no_dependencies.py`.
|
||||
"""
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
router = FastAPI()
|
||||
|
||||
|
||||
def requires_access_dag(method: str, access_entity=None):
|
||||
def check():
|
||||
...
|
||||
return check
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{dag_id}/runs/{run_id}",
|
||||
dependencies=[Depends(requires_access_dag(method="GET"))],
|
||||
)
|
||||
def get_dag_run(dag_id: str, run_id: str, session):
|
||||
"""Path params + ORM call covered by route-level guard."""
|
||||
dag_run = session.scalar(
|
||||
select(DagRun).filter_by(dag_id=dag_id, run_id=run_id)
|
||||
)
|
||||
if dag_run is None:
|
||||
raise HTTPException(404, "not found")
|
||||
return dag_run
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{dag_id}",
|
||||
dependencies=[Depends(requires_access_dag(method="DELETE"))],
|
||||
)
|
||||
def delete_dag(dag_id: str, session):
|
||||
"""Row fetch + row-variable method call covered by route-level guard."""
|
||||
dag = session.scalar(select(DagModel).where(DagModel.dag_id == dag_id))
|
||||
if dag is None:
|
||||
raise HTTPException(404, "not found")
|
||||
dag.cleanup_runs(session=session)
|
||||
16
tests/fixtures/fp_guards/framework_fastapi_route_level_auth/expectations.json
vendored
Normal file
16
tests/fixtures/fp_guards/framework_fastapi_route_level_auth/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "py.auth.missing_ownership_check" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 2,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1500,
|
||||
"max_ms_index_cold": 2000,
|
||||
"max_ms_index_warm": 800,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
40
tests/fixtures/fp_guards/framework_strapi_db_query_chain/App.ts
vendored
Normal file
40
tests/fixtures/fp_guards/framework_strapi_db_query_chain/App.ts
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Strapi-style ORM accessor: `<obj>.db.query(MODEL_UID).<orm_method>(...)`.
|
||||
// MODEL_UID is a literal model identifier (not raw SQL); the trailing
|
||||
// findOne/findMany/create/update/delete/count are intrinsically
|
||||
// parameterised — the actual SQL is generated by the ORM and per-call
|
||||
// values arrive through field-keyed object literals the driver escapes.
|
||||
//
|
||||
// FP-guard: cfg-unguarded-sink and taint-unsanitised-flow must NOT
|
||||
// fire on this shape.
|
||||
|
||||
declare const strapi: any;
|
||||
|
||||
async function getApiToken(whereParams: Record<string, unknown>) {
|
||||
return strapi.db.query('admin::api-token').findOne({
|
||||
select: ['id', 'name', 'lastUsedAt'],
|
||||
populate: ['permissions'],
|
||||
where: whereParams,
|
||||
});
|
||||
}
|
||||
|
||||
async function listTokens() {
|
||||
return strapi.db.query('admin::api-token').findMany({
|
||||
where: { type: 'read-only' },
|
||||
});
|
||||
}
|
||||
|
||||
async function createToken(data: unknown) {
|
||||
return strapi.db.query('admin::api-token').create({ data });
|
||||
}
|
||||
|
||||
async function updateToken(id: number, data: unknown) {
|
||||
return strapi.db.query('admin::api-token').update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async function deleteToken(id: number) {
|
||||
return strapi.db.query('admin::api-token').delete({ where: { id } });
|
||||
}
|
||||
|
||||
async function countTokens() {
|
||||
return strapi.db.query('admin::api-token').count();
|
||||
}
|
||||
17
tests/fixtures/fp_guards/framework_strapi_db_query_chain/expectations.json
vendored
Normal file
17
tests/fixtures/fp_guards/framework_strapi_db_query_chain/expectations.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"required_findings": [],
|
||||
"forbidden_findings": [
|
||||
{ "id_prefix": "cfg-unguarded-sink" },
|
||||
{ "id_prefix": "taint-unsanitised-flow" }
|
||||
],
|
||||
"noise_budget": {
|
||||
"max_total_findings": 3,
|
||||
"max_high_findings": 0
|
||||
},
|
||||
"performance_expectations": {
|
||||
"max_ms_no_index": 1000,
|
||||
"max_ms_index_cold": 1500,
|
||||
"max_ms_index_warm": 500,
|
||||
"ci_mode": "lenient"
|
||||
}
|
||||
}
|
||||
13
tests/fixtures/js/fetch_body_data_exfil.js
vendored
Normal file
13
tests/fixtures/js/fetch_body_data_exfil.js
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// DATA_EXFIL fixture: a fixed destination URL and an attacker-influenced
|
||||
// body. SSRF must NOT fire (destination is hardcoded) but `Cap::DATA_EXFIL`
|
||||
// must fire on the body field — request-bound bytes are leaving the process
|
||||
// via the outbound request payload.
|
||||
//
|
||||
// Driven by `fetch_data_exfil_integration_tests.rs`.
|
||||
function leakBody(req) {
|
||||
var payload = req.body.message;
|
||||
fetch('/endpoint', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
10
tests/fixtures/js/fetch_ssrf_url_tainted.js
vendored
Normal file
10
tests/fixtures/js/fetch_ssrf_url_tainted.js
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// SSRF regression fixture: attacker-controlled destination URL. SSRF must
|
||||
// fire on the URL flow (arg 0) and `Cap::DATA_EXFIL` must NOT fire — the two
|
||||
// classes share the callee but cap attribution is per-position so a tainted
|
||||
// URL never surfaces as data exfiltration.
|
||||
//
|
||||
// Driven by `fetch_data_exfil_integration_tests.rs`.
|
||||
function proxy(req) {
|
||||
var target = req.query.target;
|
||||
fetch(target);
|
||||
}
|
||||
4
tests/fixtures/mixed_project/config.rs
vendored
4
tests/fixtures/mixed_project/config.rs
vendored
|
|
@ -2,7 +2,7 @@ use std::env;
|
|||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
/// Infrastructure provisioning tool — Rust core.
|
||||
/// Infrastructure provisioning tool, Rust core.
|
||||
/// Reads infrastructure config from environment and executes provisioning commands.
|
||||
|
||||
struct InfraConfig {
|
||||
|
|
@ -56,7 +56,7 @@ fn apply_terraform() {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
/// Destroys infrastructure — reads target from env.
|
||||
/// Destroys infrastructure, reads target from env.
|
||||
/// VULN: env var flows into Command
|
||||
fn destroy_cluster() {
|
||||
let cluster = env::var("DESTROY_TARGET").unwrap();
|
||||
|
|
|
|||
17
tests/fixtures/patterns/java/negative.java
vendored
17
tests/fixtures/patterns/java/negative.java
vendored
|
|
@ -1,5 +1,9 @@
|
|||
import java.sql.*;
|
||||
import java.security.SecureRandom;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.apache.commons.text.StringSubstitutor;
|
||||
|
||||
class Negative {
|
||||
// Safe: parameterized query
|
||||
|
|
@ -19,4 +23,17 @@ class Negative {
|
|||
void safeLiteralQuery(Statement stmt) throws Exception {
|
||||
stmt.executeQuery("SELECT COUNT(*) FROM users");
|
||||
}
|
||||
|
||||
// Safe: SnakeYAML 2.0 / explicit SafeConstructor — CVE-2022-1471 fix shape.
|
||||
void safeSnakeyamlSafeConstructor(String body) {
|
||||
LoaderOptions opts = new LoaderOptions();
|
||||
Yaml yaml = new Yaml(new SafeConstructor(opts));
|
||||
Object data = yaml.load(body);
|
||||
}
|
||||
|
||||
// Safe: empty StringSubstitutor — no interpolator factory — CVE-2022-42889 fix shape.
|
||||
String safeStringSubstitutorPassthrough(String input) {
|
||||
StringSubstitutor s = new StringSubstitutor();
|
||||
return s.replace(input);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
tests/fixtures/patterns/java/positive.java
vendored
14
tests/fixtures/patterns/java/positive.java
vendored
|
|
@ -1,6 +1,8 @@
|
|||
import java.io.*;
|
||||
import java.util.Random;
|
||||
import java.security.MessageDigest;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.apache.commons.text.StringSubstitutor;
|
||||
|
||||
class Positive {
|
||||
// java.deser.readobject
|
||||
|
|
@ -45,4 +47,16 @@ class Positive {
|
|||
void triggerGetWriterPrint(javax.servlet.http.HttpServletResponse resp) throws Exception {
|
||||
resp.getWriter().println("<html>" + "data" + "</html>");
|
||||
}
|
||||
|
||||
// java.deser.snakeyaml_unsafe_constructor — CVE-2022-1471 regression guard.
|
||||
void triggerSnakeyamlUnsafeConstructor() throws Exception {
|
||||
Yaml yaml = new Yaml();
|
||||
Object data = yaml.load("payload");
|
||||
}
|
||||
|
||||
// java.code_exec.text4shell_interpolator — CVE-2022-42889 regression guard.
|
||||
String triggerText4ShellInterpolator(String input) {
|
||||
StringSubstitutor s = StringSubstitutor.createInterpolator();
|
||||
return s.replace(input);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
tests/fixtures/patterns/python/positive.py
vendored
8
tests/fixtures/patterns/python/positive.py
vendored
|
|
@ -42,6 +42,14 @@ def trigger_yaml(data):
|
|||
def trigger_sql_concat(cursor, user):
|
||||
cursor.execute("SELECT * FROM users WHERE name = '" + user + "'")
|
||||
|
||||
# py.sqli.execute_format (f-string variant)
|
||||
def trigger_sql_fstring(cursor, user):
|
||||
cursor.execute(f"SELECT * FROM users WHERE name = '{user}'")
|
||||
|
||||
# py.sqli.text_format
|
||||
def trigger_sqlalchemy_text_fstring(connection, user):
|
||||
connection.execute(text(f"SELECT * FROM users WHERE name = '{user}'"))
|
||||
|
||||
# py.crypto.md5
|
||||
def trigger_md5(data):
|
||||
hashlib.md5(data)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ mod auth {
|
|||
|
||||
// Negative control: the handler validates ownership via
|
||||
// `authz::require_group_member(...)?` before the realtime publish. Phase C
|
||||
// should NOT emit `rs.auth.missing_ownership_check.taint` here — the
|
||||
// should NOT emit `rs.auth.missing_ownership_check.taint` here, the
|
||||
// sanitizer clears `UNAUTHORIZED_ID` from the argument SSA values.
|
||||
pub async fn handle_publish_checked(Path(group_id): Path<i64>) -> Result<&'static str, ()> {
|
||||
let user = auth::current_user();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"description": "fetch({url: taintedUrl, body: fixed}) — destination-aware object-literal case. url is tainted, must fire.",
|
||||
"tags": ["taint", "ssrf", "fetch", "destination-aware", "object-config"],
|
||||
"description": "fetch({url: taintedUrl, body: fixed}) — destination-aware object-literal case. url is tainted (SSRF), body is fixed. SSRF must fire and the cross-boundary data-exfiltration class (Cap::DATA_EXFIL) must NOT fire — the two classes share the callee but cap attribution is per-position.",
|
||||
"tags": ["taint", "ssrf", "fetch", "destination-aware", "object-config", "cap-attribution"],
|
||||
"modes": ["full"],
|
||||
"expected": [
|
||||
{
|
||||
|
|
@ -10,6 +10,12 @@
|
|||
"line_range": [6, 14],
|
||||
"evidence_contains": [],
|
||||
"notes": "req.query.target → fetch({url: target, ...}) — tainted destination field under object-literal shape."
|
||||
},
|
||||
{
|
||||
"rule_id": "taint-data-exfiltration",
|
||||
"must_not_match": true,
|
||||
"line_range": [6, 14],
|
||||
"notes": "body is a fixed literal '{}' — DATA_EXFIL must NOT fire on this site (regression guard for per-cap attribution)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"description": "fetch() request body carries attacker-controlled content but the destination URL is fixed. Under the destination-aware SSRF gate, only taint reaching the URL (arg 0 / object `url` field) activates — body taint must be silenced.",
|
||||
"tags": ["taint", "ssrf", "fetch", "destination-aware", "regression-fp"],
|
||||
"description": "fetch() with a fixed destination URL and an attacker-controlled body. SSRF must NOT fire (destination is not attacker-influenced) and the cross-boundary data-exfiltration class (Cap::DATA_EXFIL) MUST fire on the body field.",
|
||||
"tags": ["taint", "data-exfil", "fetch", "destination-aware", "cap-attribution"],
|
||||
"modes": ["full"],
|
||||
"expected": [
|
||||
{
|
||||
|
|
@ -8,6 +8,12 @@
|
|||
"must_not_match": true,
|
||||
"line_range": [7, 14],
|
||||
"notes": "fetch('/api/telemetry', {body: payload}) — arg 0 is a fixed string, body taint must not fire as SSRF."
|
||||
},
|
||||
{
|
||||
"rule_id": "taint-data-exfiltration",
|
||||
"must_match": true,
|
||||
"line_range": [7, 14],
|
||||
"notes": "Body field carries req.body.message → must fire DATA_EXFIL (sensitive data leaving the process via outbound request payload)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::env;
|
|||
use std::fs;
|
||||
|
||||
// Wrapper whose replace chain strips only unrelated characters. The scanner
|
||||
// must NOT treat this as a path-traversal sanitizer — the taint path should
|
||||
// must NOT treat this as a path-traversal sanitizer, the taint path should
|
||||
// still be flagged.
|
||||
fn rewrite(s: &str) -> String {
|
||||
s.replace("foo", "bar").replace("baz", "qux")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Session-module validate: accidentally shells out with its param.
|
||||
// Same name + arity as auth::token::validate — ambiguous without a use map.
|
||||
// Same name + arity as auth::token::validate, ambiguous without a use map.
|
||||
// If cross-file resolution incorrectly targets this function from main.rs,
|
||||
// the param taint from env::var will flow into Command::arg → taint finding.
|
||||
pub fn validate(input: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Token-module validate: strips shell metacharacters and returns a safe value.
|
||||
// No sink in the body — purely a pass-through sanitizer.
|
||||
// No sink in the body, purely a pass-through sanitizer.
|
||||
pub fn validate(input: &str) -> String {
|
||||
input.replace(['&', ';', '|', '$', '`', '\\', '"', '\''], "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::auth::token::validate;
|
||||
|
||||
// `validate(&cmd)` must resolve unambiguously to `auth::token::validate`
|
||||
// (a pass-through sanitizer) — NOT `auth::session::validate` (which sinks
|
||||
// (a pass-through sanitizer), NOT `auth::session::validate` (which sinks
|
||||
// its arg into std::process::Command). A correct use-map driven resolver
|
||||
// produces zero cross-file taint findings on this file.
|
||||
fn main() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::process::Command;
|
||||
|
||||
// #[inline] is NOT an auth attribute — finding should fire.
|
||||
// #[inline] is NOT an auth attribute, finding should fire.
|
||||
#[inline]
|
||||
fn handle_request(req: &str) {
|
||||
Command::new("sh").arg("-c").arg("ls /tmp").status().unwrap();
|
||||
|
|
|
|||
2
tests/fixtures/state/rust_box_owned.rs
vendored
2
tests/fixtures/state/rust_box_owned.rs
vendored
|
|
@ -1,5 +1,5 @@
|
|||
fn boxed() {
|
||||
let b = Box::new(42);
|
||||
println!("{}", b);
|
||||
// b dropped — no leak
|
||||
// b dropped, no leak
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ fn read_file() {
|
|||
let mut f = File::open("/tmp/test").unwrap();
|
||||
let mut buf = String::new();
|
||||
f.read_to_string(&mut buf).unwrap();
|
||||
// f dropped by RAII — no leak
|
||||
// f dropped by RAII, no leak
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ enum Cap {
|
|||
pub fn dispatch(cap: Cap) {
|
||||
let user_cmd = env::var("USER_CMD").unwrap_or_default();
|
||||
match cap {
|
||||
// Raw arm — tainted user_cmd flows directly into the shell.
|
||||
// Raw arm, tainted user_cmd flows directly into the shell.
|
||||
Cap::Raw => {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
|
|
@ -27,7 +27,7 @@ pub fn dispatch(cap: Cap) {
|
|||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
// Safe arm — allowlist-guarded execution.
|
||||
// Safe arm, allowlist-guarded execution.
|
||||
Cap::Safe => {
|
||||
let allowed = ["ls", "date"];
|
||||
if allowed.contains(&user_cmd.as_str()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue