introspectre 1.4.0

A GraphQL offensive-security engine: introspection-driven schema analysis, active vulnerability probing, and an interactive attack-surface report.
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
405
406
407
408
409
410
411
412
413
414
415
416
417
use crate::utils::{matches_pattern, user_types};
use crate::config::PatternConfig;
use crate::types::{AffectedLocation, Confidence, EvidenceLevel, Finding, FindingStatus, GqlSchema, GqlType, Severity};
use std::collections::{HashMap, HashSet};

pub fn check_information_exposure(
    schema: &GqlSchema,
    patterns: &PatternConfig,
    findings: &mut Vec<Finding>,
) {
    let types = user_types(schema);
    let type_map: HashMap<&str, &GqlType> = schema
        .types
        .iter()
        .filter_map(|t| t.name.as_deref().map(|n| (n, t)))
        .collect();

    let has_introspection = schema.types.iter().any(|t| {
        t.name
            .as_deref()
            .map(|n| n.starts_with("__"))
            .unwrap_or(false)
    });
    if has_introspection {
        findings.push(Finding {
            id: "introspection-enabled",
            severity: Severity::Info,
            title: "Introspection Enabled",
            description: "GraphQL introspection is enabled. Attackers can enumerate all types, fields, queries, and mutations — essentially a free schema map for targeting attacks.".into(),
            affected: vec![AffectedLocation::Field("__schema".into(), "types".into()), AffectedLocation::Field("__schema".into(), "directives".into())],
            remediation: "Disable introspection in production (set `introspection: false` in your server config). Allow-list only via internal tooling or developer environments.",
            first_step: Some("Run an introspection query manually to see if it returns the full schema structure.".into()),
            references: vec!["CWE-200: Information Exposure", "OWASP API Security Top 10"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mut sensitive: Vec<AffectedLocation> = Vec::new();
    for t in &types {
        let type_name = t.name.as_deref().unwrap_or("?");
        if let Some(fields) = &t.fields {
            for f in fields {
                if matches_pattern(&f.name, &patterns.sensitive_fields.names) {
                    sensitive.push(AffectedLocation::Field(type_name.into(), f.name.clone()));
                }
            }
        }
        if let Some(input_fields) = &t.input_fields {
            for f in input_fields {
                if matches_pattern(&f.name, &patterns.sensitive_fields.names) {
                    sensitive.push(AffectedLocation::Field(type_name.into(), f.name.clone()));
                }
            }
        }
    }
    if !sensitive.is_empty() {
        findings.push(Finding {
            id: "sensitive-fields",
            severity: Severity::High,
            title: "Sensitive Field Names Exposed",
            description: format!(
                "{} field(s) with names suggesting sensitive data (passwords, tokens, secrets, keys, etc.) are present in the schema. These may be accessible without authorization.",
                sensitive.len()
            ),
            affected: sensitive.into_iter().take(25).collect(),
            remediation: "Add field-level authorization for all sensitive fields. Consider masking, omitting from schema entirely, or using opaque identifiers.",
            first_step: Some("Query one of the highlighted sensitive fields with a dummy value to check for authorization errors.".into()),
            references: vec!["OWASP API3: Excessive Data Exposure", "CWE-312: Cleartext Storage"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mut deprecated: Vec<AffectedLocation> = Vec::new();
    for t in &types {
        let type_name = t.name.as_deref().unwrap_or("?");
        if let Some(fields) = &t.fields {
            for f in fields {
                if f.is_deprecated.unwrap_or(false) {
                    deprecated.push(AffectedLocation::Field(type_name.into(), f.name.clone()));
                }
            }
        }
    }

    if !deprecated.is_empty() {
        findings.push(Finding {
            id: "deprecated-fields",
            severity: Severity::Low,
            title: "Deprecated Fields Still Queryable",
            description: format!(
                "{} deprecated field(s) remain accessible. These may have weaker validation, outdated authorization logic, or expose legacy data paths.",
                deprecated.len()
            ),
            affected: deprecated.into_iter().take(20).collect(),
            remediation: "Remove deprecated fields or block access server-side. If kept for backward compatibility, ensure they have equivalent security controls to new fields.",
            first_step: Some("Attempt to query a deprecated field to see if the server still resolves it or returns a warning.".into()),
            references: vec!["CWE-477: Use of Obsolete Function"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let sensitive_enums: Vec<AffectedLocation> = types
        .iter()
        .filter(|t| t.kind.as_deref() == Some("ENUM"))
        .filter(|t| {
            let name_sensitive = t
                .name
                .as_deref()
                .map(|n| matches_pattern(n, &patterns.sensitive_fields.names))
                .unwrap_or(false);
            let values_sensitive = t
                .enum_values
                .as_ref()
                .map(|vs| {
                    vs.iter()
                        .any(|v| matches_pattern(&v.name, &patterns.sensitive_fields.names))
                })
                .unwrap_or(false);
            name_sensitive || values_sensitive
        })
        .map(|t| {
            AffectedLocation::Type(t.name.clone().unwrap_or_default())
        })
        .collect();

    if !sensitive_enums.is_empty() {
        findings.push(Finding {
            id: "sensitive-enum-values",
            severity: Severity::Low,
            title: "Enums With Sensitive Values Exposed",
            description: format!(
                "{} enum type(s) expose names suggesting internal roles, permissions, or states. Attackers can enumerate valid states to assist privilege escalation or IDOR attacks.",
                sensitive_enums.len()
            ),
            affected: sensitive_enums,
            remediation: "Avoid exposing internal role/permission enums publicly. Use opaque identifiers and validate enum values strictly server-side.",
            first_step: Some("Review the enum values in the schema to see if they expose internal business logic or roles.".into()),
            references: vec!["CWE-200: Information Exposure"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let bloated: Vec<AffectedLocation> = types
        .iter()
        .filter(|t| t.kind.as_deref() == Some("OBJECT"))
        .filter(|t| {
            let n = t.name.as_deref().unwrap_or("");
            !["Query", "Mutation", "Subscription"].contains(&n)
                && t.fields.as_ref().map(|f| f.len() > 30).unwrap_or(false)
        })
        .map(|t| {
            AffectedLocation::Type(t.name.clone().unwrap_or_default())
        })
        .collect();

    if !bloated.is_empty() {
        findings.push(Finding {
            id: "field-bloat",
            severity: Severity::Low,
            title: "Over-Exposed Object Types (Field Bloat)",
            description: format!(
                "{} object type(s) expose more than 30 fields. Overly wide types increase the risk of unintentional data exposure and make authorization auditing harder.",
                bloated.len()
            ),
            affected: bloated,
            remediation: "Apply principle of least privilege to schema design. Split types by role (e.g. UserPublic vs UserAdmin). Add field-level resolvers with auth checks.",
            first_step: Some("Inspect one of the bloated types to see if it contains internal fields that shouldn't be public.".into()),
            references: vec!["OWASP API3: Excessive Data Exposure"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mutation_name = schema.mutation_type.as_ref().map(|t| t.name.as_str());
    let mutation_fields = schema.fields_for_type(mutation_name);
    let mut untyped_mutations: Vec<AffectedLocation> = Vec::new();
    for f in &mutation_fields {
        let is_untyped = f
            .args
            .as_ref()
            .map(|args| {
                args.iter().any(|a| {
                    a.arg_type
                        .as_ref()
                        .and_then(|t| t.unwrap_type_name())
                        .map(|n| n == "String" || n == "ID")
                        .unwrap_or(false)
                })
            })
            .unwrap_or(false);

        if is_untyped {
            untyped_mutations.push(AffectedLocation::Field("Mutation".into(), f.name.clone()));
        }
    }

    if !untyped_mutations.is_empty() {
        findings.push(Finding {
            id: "raw-scalar-mutation-args",
            severity: Severity::Low,
            title: "Mutations Accept Raw String / ID Arguments",
            description: format!(
                "{} mutation(s) accept raw String or ID arguments. Without custom scalars or input validation, these are potential injection vectors (SQLi, NoSQLi, SSRF).",
                untyped_mutations.len()
            ),
            affected: untyped_mutations.into_iter().take(20).collect(),
            remediation: "Replace generic String/ID arguments with typed Input objects and custom scalars (e.g. EmailAddress, URL, UUID). Validate all inputs server-side regardless of scalar type.",
            first_step: Some("Test one of these mutations with unexpected characters (like single quotes) to check for injection vulnerabilities.".into()),
            references: vec!["CWE-20: Improper Input Validation", "OWASP API8: Injection"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let debug_types: Vec<AffectedLocation> = types
        .iter()
        .filter(|t| {
            t.name
                .as_deref()
                .map(|n| matches_pattern(n, &patterns.debug_types.names))
                .unwrap_or(false)
        })
        .map(|t| AffectedLocation::Type(t.name.clone().unwrap_or_default()))
        .collect();

    if !debug_types.is_empty() {
        findings.push(Finding {
            id: "debug-types-exposed",
            severity: Severity::Medium,
            title: "Debug / Admin / Internal Types Exposed",
            description: format!(
                "{} type name(s) suggest internal, debug, or admin functionality is exposed in the public schema. These are high-value targets for attackers.",
                debug_types.len()
            ),
            affected: debug_types,
            remediation: "Remove internal/debug types from the public schema. Use schema stitching or type visibility rules to expose only what external clients need.",
            first_step: Some("Try to query a field on one of these debug types to see if it requires special permissions.".into()),
            references: vec!["CWE-489: Active Debug Code", "OWASP API7: Security Misconfiguration"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mut mass_assignment: Vec<AffectedLocation> = Vec::new();
    for f in &mutation_fields {
        if let Some(args) = &f.args {
            for arg in args {
                if let Some(input_type_name) =
                    arg.arg_type.as_ref().and_then(|t| t.unwrap_type_name())
                {
                    if let Some(input_type) = type_map.get(input_type_name.as_str()) {
                        if let Some(input_fields) = &input_type.input_fields {
                            for input_field in input_fields {
                                if matches_pattern(
                                    &input_field.name,
                                    &patterns.sensitive_fields.names,
                                ) {
                                    mass_assignment.push(AffectedLocation::Argument("Mutation".into(), f.name.clone(), input_field.name.clone()));
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    if !mass_assignment.is_empty() {
        findings.push(Finding {
            id: "mass-assignment",
            severity: Severity::Medium,
            title: "Potential Mass Assignment in Mutation Input",
            description: format!(
                "{} mutation input field(s) match sensitive naming patterns. If these fields are not explicitly protected by server-side logic, attackers may be able to modify sensitive state (e.g. roles, permissions, internal flags) by including them in the mutation payload.",
                mass_assignment.len()
            ),
            affected: mass_assignment.into_iter().take(25).collect(),
            remediation: "Use specific 'Update' input types that only include user-editable fields. Never bind raw input objects directly to database models (Mass Assignment). Implement strict field-level validation and authorization.",
            first_step: Some("Attempt to include one of these sensitive fields in a mutation request to see if the server accepts and processes it.".into()),
            references: vec!["OWASP API6: Mass Assignment", "CWE-915: Improperly Controlled Modification"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Theoretical,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mut mutation_name_set: HashSet<String> = HashSet::new();
    for m in &mutation_fields {
        mutation_name_set.insert(m.name.to_lowercase());
    }

    let mut operation_gap_possibilities: Vec<AffectedLocation> = Vec::new();
    for m in &mutation_fields {
        let lower = m.name.to_lowercase();
        if !lower.starts_with("create") {
            continue;
        }

        let resource = &m.name["create".len()..];
        if resource.is_empty() {
            continue;
        }

        let resource_lower = resource.to_lowercase();
        let update_lower = format!("update{}", resource_lower);
        let delete_lower = format!("delete{}", resource_lower);

        let mut missing = false;
        if !mutation_name_set.contains(&update_lower) || !mutation_name_set.contains(&delete_lower) {
            missing = true;
        }

        if missing {
            operation_gap_possibilities.push(AffectedLocation::Field("Mutation".into(), m.name.clone()));
        }
    }

    if !operation_gap_possibilities.is_empty() {
        findings.push(Finding {
            id: "operation-name-gaps",
            severity: Severity::Low,
            title: "Undocumented Operation Name Gaps",
            description: format!(
                "{} create* mutation(s) are missing expected update/delete counterparts. This can indicate hidden or inconsistent operation design worth deeper review.",
                operation_gap_possibilities.len()
            ),
            affected: operation_gap_possibilities.into_iter().take(30).collect(),
            remediation: "Review mutation lifecycle consistency (create/update/delete) per resource and ensure undocumented operations are not exposed elsewhere with weaker controls.",
            first_step: Some("Check if update/delete operations exist for these resources but are named differently or hidden.".into()),
            references: vec!["OWASP API9: Improper Inventory Management"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Possible,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }

    let mut leakage_possibilities: Vec<AffectedLocation> = Vec::new();
    for t in &types {
        if t.kind.as_deref() != Some("OBJECT") {
            continue;
        }

        let type_name = t.name.as_deref().unwrap_or("");
        if type_name.is_empty() || ["Query", "Mutation", "Subscription"].contains(&type_name) {
            continue;
        }

        let mut user_fields = false;
        let mut cross_fields = false;

        if let Some(fields) = &t.fields {
            for f in fields {
                let lower = f.name.to_lowercase();
                if patterns
                    .user_scope_hints
                    .names
                    .iter()
                    .any(|h| lower.contains(&h.to_lowercase()))
                {
                    user_fields = true;
                }
                if patterns
                    .cross_domain_hints
                    .names
                    .iter()
                    .any(|h| lower.contains(&h.to_lowercase()))
                {
                    cross_fields = true;
                }
            }
        }

        if user_fields && cross_fields {
            leakage_possibilities.push(AffectedLocation::Type(type_name.into()));
        }
    }

    if !leakage_possibilities.is_empty() {
        findings.push(Finding {
            id: "cross-object-leakage",
            severity: Severity::Medium,
            title: "Cross-Object Field Leakage Heuristic",
            description: format!(
                "{} object type(s) combine user-ownership fields with cross-domain/private resource fields, which can indicate over-broad object exposure.",
                leakage_possibilities.len()
            ),
            affected: leakage_possibilities.into_iter().take(25).collect(),
            remediation: "Split multi-domain objects into least-privilege response types and enforce field-level authorization per ownership domain before serialization.",
            first_step: Some("Review the fields on these types to see if they mix private user data with public or cross-domain data.".into()),
            references: vec!["OWASP API3: Excessive Data Exposure", "OWASP API1: Broken Object Level Authorization"],
            status: FindingStatus::Inferred,
            confidence: Confidence::Possible,
            evidence_level: EvidenceLevel::Inferred,
            poc: None,
        });
    }
}