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
//! Regression lock: multiline concatenation **JOIN** contracts.
//!
//! The multiline preprocessor (`crates/scanner/src/multiline/`) reassembles a
//! secret that a source file split across lines with an explicit string
//! concatenation operator (`+` in Java/JS/Python/C#, `.` in PHP/Perl) or a
//! trailing-operator continuation, so the scanner sees the whole credential as
//! one contiguous span. This file pins the JOIN behaviour end to end:
//!
//! * POSITIVE, a `"AKIA" +` / `"ghp_" .` fragment continued on the next line
//! reassembles into the EXACT credential bytes, appended after the untouched
//! original body, with `original_end` always equal to the input length.
//! * NEGATIVE TWIN, ordinary two-line prose, an unquoted arithmetic `+`, a
//! `.` member access, and a base64 value whose alphabet contains `+`/`.`
//! INSIDE a quoted literal are NOT falsely joined (no synthetic candidate).
//! * BOUNDARY: `MultilineConfig::max_join_lines` is the join window: a chain
//! that fits reassembles the whole key; one line past the window truncates
//! the join; a window of 1 disables joining entirely.
//!
//! HOST-INDEPENDENCE: every function under test is a pure CPU string transform
//! (no Hyperscan / SIMD / GPU on this path), so these assertions hold
//! byte-identically on every host (there is no accelerator to be absent).
//!
//! Source under test:
//! * crates/scanner/src/multiline/preprocessor.rs
//! (`preprocess_multiline`, `process_line_chain` join walk, join window)
//! * crates/scanner/src/multiline/string_extract.rs
//! (`extract_plus_concatenation`, `extract_dot_concatenation`,
//! `split_concatenation_operators`, `strip_assignment_prefix`)
//! * crates/scanner/src/multiline/config.rs
//! (`has_concatenation_indicators`, `MultilineConfig::max_join_lines`)
#![cfg(feature = "multiline")]
use keyhog_scanner::testing::fragment_cache::FragmentCache;
use keyhog_scanner::testing::multiline::{
extract_dot_concatenation_for_test, extract_plus_concatenation_for_test,
filter_line_content_for_test, has_concatenation_indicators_for_test, preprocess_multiline,
preprocess_multiline_for_test, MultilineConfig,
};
// ── POSITIVE: `+`-split credential reassembled across two source lines ────────
/// The canonical join: `token = "ghp_" +` continued by `"abcdef…"` on line two
/// reassembles into the whole `ghp_…` token, appended after the untouched
/// original two-line body. `original_end` stays the exact input byte length.
#[test]
fn plus_split_two_lines_reassembles_exact_appended_bytes() {
let text = "token = \"ghp_\" +\n\"abcdef0123456789abcdef01\"";
let (joined, original_end) = preprocess_multiline_for_test(text);
assert_eq!(
joined,
format!("{text}\nghp_abcdef0123456789abcdef01"),
"the two `+`-joined fragments must reassemble into one appended candidate"
);
assert_eq!(
original_end,
text.len(),
"original_end must equal the exact input byte length on the join path"
);
assert!(
joined.starts_with(text),
"the original bytes must be carried through verbatim before the append"
);
}
/// Law-10 regression: a JS/TS SOURCE file that legitimately OPENS with an object
/// literal (`{ … }`) must still be multiline-preprocessed. The old leading-`{`
/// reject in `has_concatenation_indicators` / `preprocess_multiline` force-passed
/// such a file through and silently dropped its entire multiline surface, so a
/// `"ghp_" +\n"…"`-split token inside the object never reassembled. The
/// structural JSON discriminator now keeps it: an object literal is NOT strict
/// JSON (unquoted key, `+` splice), so it falls through and its split credential
/// reassembles into one contiguous appended candidate.
#[test]
fn js_object_literal_opening_brace_still_reassembles_split_secret() {
// Opens with `{` exactly like a JSON config would, but is JS (unquoted key,
// `+` concatenation) (precisely the case the first-byte heuristic dropped).
let js = "{ token: \"ghp_\" +\n\"abcdef0123456789abcdef01\" }";
assert!(
has_concatenation_indicators_for_test(js),
"a JS object literal with a `+`-split string must be treated as a \
concatenation candidate despite the leading `{{`"
);
let (joined, original_end) = preprocess_multiline_for_test(js);
assert!(
joined.starts_with(js),
"the original object-literal bytes must be carried through verbatim first"
);
assert_eq!(
original_end,
js.len(),
"original_end must equal the exact input byte length (append, not rewrite)"
);
assert!(
joined.contains("ghp_abcdef0123456789abcdef01"),
"the `+`-split token inside the object literal must reassemble into one \
contiguous span; got {joined:?}"
);
}
/// Adversarial twin: a buffer that opens with `{` and IS strict JSON (quoted
/// key, no concat) must still be rejected, the structural discriminator skips
/// genuine JSON data, so the fix does not start preprocessing real config files.
#[test]
fn strict_json_object_opening_brace_is_still_rejected() {
let json = "{\n \"token\": \"ghp_abcdef0123456789abcdef01\"\n}";
assert!(
!has_concatenation_indicators_for_test(json),
"a strict-JSON object body must remain a passthrough (not a concat candidate)"
);
let (joined, original_end) = preprocess_multiline_for_test(json);
assert_eq!(
joined, json,
"strict JSON must pass through byte-identically"
);
assert_eq!(original_end, json.len());
}
/// The `+` extractor on the FIRST line of a split reports the leading literal
/// value AND `continues == true` (the trailing join `+`), which is what makes
/// the chain walker pull the next line.
#[test]
fn plus_extractor_reports_continuation_on_trailing_operator() {
assert_eq!(
extract_plus_concatenation_for_test("token = \"ghp_\" +"),
Some(("ghp_".to_string(), true)),
"a trailing join `+` yields the partial value and continues == true"
);
}
/// Two quoted literals joined on ONE line with `+` reassemble to the whole key
/// with `continues == false` (no trailing operator, the chain ends here).
#[test]
fn plus_extractor_two_literal_join_single_line() {
assert_eq!(
extract_plus_concatenation_for_test("x = \"AKIA\" + \"IOSFODNN7EXAMPLE\""),
Some(("AKIAIOSFODNN7EXAMPLE".to_string(), false)),
);
}
// ── Tier-B var-declaration keyword strip (rules/multiline-var-decl-keywords) ──
/// Every language keyword in the Tier-B list is stripped off an assignment line
/// so the RHS value is what gets extracted. This pins the data-driven migration
/// (the keyword set moved out of a hardcoded `trim_start_matches` chain into
/// `rules/multiline-var-decl-keywords.toml`): each keyword must still be
/// recognized, and the exact RHS value must be returned.
#[test]
fn tier_b_var_decl_keywords_are_stripped_before_value_extraction() {
// (line, expected RHS), one per shipped keyword, across JS/TS, Kotlin/Scala,
// Java, C++, VB, and Perl declaration syntaxes.
let cases = [
("const apiKey = sk_live_abcdef", "sk_live_abcdef"),
("let token = ghp_deadbeef01", "ghp_deadbeef01"),
("var secret = AKIAIOSFODNN7", "AKIAIOSFODNN7"),
("val password = hunter2value", "hunter2value"),
("final creds = xoxb_slack_tok", "xoxb_slack_tok"),
("static apiKey = glpat_deadbeef", "glpat_deadbeef"),
("string conn = postgres_secret", "postgres_secret"),
("String conn = mysql_secret_00", "mysql_secret_00"),
("auto handle = aws_session_tok", "aws_session_tok"),
("dim key = azure_key_value_9", "azure_key_value_9"),
("my $tok = perl_lexical_secret", "perl_lexical_secret"),
];
for (line, expected) in cases {
assert_eq!(
filter_line_content_for_test(line),
expected,
"keyword prefix in {line:?} must be stripped, leaving the RHS value"
);
}
}
/// Stacked declaration keywords (`static final x = …`) are all stripped, and a
/// keyword that is only a SUBSTRING of an identifier (`myToken`, `constant`) is
/// NOT stripped, the prefix carries a trailing space so it only matches a real
/// leading declaration keyword.
#[test]
fn tier_b_var_decl_keyword_strip_is_exact_and_stacked() {
assert_eq!(
filter_line_content_for_test("final static token = deadbeefcafef00d"),
"deadbeefcafef00d",
"stacked keywords must both be stripped"
);
// `myToken` starts with `my` but not `my `: must be left intact as the LHS
// identifier, so the RHS is still what is returned after the `=`.
assert_eq!(
filter_line_content_for_test("myToken = perl_looking_but_not"),
"perl_looking_but_not",
"a keyword that is only an identifier substring must not be stripped"
);
// No keyword, no assignment: the line is returned unchanged.
assert_eq!(
filter_line_content_for_test("justabareword"),
"justabareword",
"a line with neither a keyword nor an assignment is returned verbatim"
);
}
// ── POSITIVE: PHP/Perl `.`-operator join ─────────────────────────────────────
/// PHP `.`-concatenation of two quoted literals reassembles to the whole value,
/// `continues == false`.
#[test]
fn dot_extractor_php_two_literal_join_single_line() {
assert_eq!(
extract_dot_concatenation_for_test("$t = \"ghp_\" . \"abcdef012345\""),
Some(("ghp_abcdef012345".to_string(), false)),
);
}
/// A trailing join `.` continuation (`$t = "ghp_" .` continued next line) yields
/// the partial literal and `continues == true`.
#[test]
fn dot_extractor_trailing_operator_continues() {
assert_eq!(
extract_dot_concatenation_for_test("$t = \"ghp_\" ."),
Some(("ghp_".to_string(), true)),
);
}
// ── NEGATIVE TWIN: shapes that must NOT be falsely joined ─────────────────────
/// Plain two-line prose trips NO concatenation indicator, so the preprocessor
/// passes it through byte-identically with nothing appended.
#[test]
fn plain_two_line_prose_is_not_falsely_joined() {
let prose = "This is a normal paragraph\nwith no secret content here";
assert!(
!has_concatenation_indicators_for_test(prose),
"prose must not register as a concatenation candidate"
);
let (joined, original_end) = preprocess_multiline_for_test(prose);
assert_eq!(
joined, prose,
"prose must be carried through with no appended candidate"
);
assert_eq!(original_end, prose.len());
}
/// Adversarial: a base64 value whose alphabet contains `+` INSIDE a single
/// quoted literal (no join `+` outside quotes) must NOT be split, the `+` is
/// value bytes, not a join operator, so no candidate is synthesized.
#[test]
fn base64_plus_inside_quoted_literal_is_not_split() {
assert_eq!(
extract_plus_concatenation_for_test("x = \"aGVsbG8+d29ybGQ=\""),
None,
"a `+` inside a base64 literal is value bytes, not a join operator"
);
}
/// Negative twin: an unquoted arithmetic `+` between two identifiers is not a
/// string-literal join (the structural resolver, not this extractor, owns
/// variable-reference joins), so the `+` extractor returns None.
#[test]
fn unquoted_arithmetic_plus_is_not_a_string_join() {
assert_eq!(
extract_plus_concatenation_for_test("total = count + amount"),
None,
);
}
/// Negative twin: a `.` that is member access (`arr["k"].length`) has a
/// non-quoted identifier on the right of the join, so no two quoted literals
/// contribute and the `.` extractor returns None.
#[test]
fn dot_member_access_is_not_a_string_join() {
assert_eq!(
extract_dot_concatenation_for_test("y = arr[\"k\"].length"),
None,
);
}
/// Negative twin: a single quoted value that merely CONTAINS `.` bytes
/// (`"api.example.com"`) is one literal, not a two-literal join, so it is never
/// reassembled into a synthetic candidate.
#[test]
fn dot_inside_single_quoted_literal_is_not_joined() {
assert_eq!(
extract_dot_concatenation_for_test("msg = \"api.example.com\""),
None,
);
}
// ── has_concatenation_indicators gate: positive + JSON negative ───────────────
/// The indicator gate is TRUE for a real `+`-split body and FALSE for a JSON
/// object body (its first non-space byte `{` short-circuits the scan), so a
/// JSON-embedded secret is never fed through the join reassembler.
#[test]
fn indicator_gate_true_for_plus_split_false_for_json() {
assert!(has_concatenation_indicators_for_test(
"api_key = \"AK\" +\n\"IAIOSFODNN7EXAMPLE\""
));
assert!(!has_concatenation_indicators_for_test(
"{\n \"api_key\": \"AKIAIOSFODNN7EXAMPLE\"\n}"
));
}
// ── BOUNDARY: the `max_join_lines` join window ───────────────────────────────
/// Text used by the join-window boundary tests: three `+`-joined literals that,
/// fully reassembled, spell `AKIAIOSFODNN7EXAMPLE`. The assignment name `k` is
/// deliberately NOT credential-like, so the structural reassembler contributes
/// nothing and the join window is the only variable.
const WINDOW_TEXT: &str = "k = \"AKIA\" +\n\"IOSF\" +\n\"ODNN7EXAMPLE\"";
fn preprocess_with_window(text: &str, max_join_lines: usize) -> (String, usize) {
let config = MultilineConfig {
max_join_lines,
..MultilineConfig::default()
};
let cache = FragmentCache::new(64);
let pre = preprocess_multiline(text, &config, &cache);
let out: &str = &pre.text;
(out.to_string(), pre.original_end)
}
/// Inside the window (limit 3 for a 3-fragment chain) the whole key reassembles:
/// the appended candidate is exactly `AKIAIOSFODNN7EXAMPLE`.
#[test]
fn join_window_within_limit_reassembles_full_key() {
let (out, original_end) = preprocess_with_window(WINDOW_TEXT, 3);
assert_eq!(
out,
format!("{WINDOW_TEXT}\nAKIAIOSFODNN7EXAMPLE"),
"a chain that fits the window reassembles the whole key"
);
assert_eq!(original_end, WINDOW_TEXT.len());
assert!(out.contains("AKIAIOSFODNN7EXAMPLE"));
}
/// One line past the window (limit 2 for a 3-fragment chain) the join stops at
/// the window edge: only `AKIAIOSF` is reassembled, and the full-key token
/// `AKIAIOSFODNN7EXAMPLE` NEVER appears.
#[test]
fn join_window_past_limit_truncates_the_join() {
let (out, original_end) = preprocess_with_window(WINDOW_TEXT, 2);
assert_eq!(
original_end,
WINDOW_TEXT.len(),
"original_end is unaffected by where the join window cuts"
);
assert!(
out.contains("AKIAIOSF"),
"the two fragments inside the window still reassemble: {out:?}"
);
assert!(
!out.contains("AKIAIOSFODNN7EXAMPLE"),
"the third fragment is past the window and must not join: {out:?}"
);
}
/// A window of 1 disables joining outright: a two-line `+`-split leaves the
/// leading fragment and its continuation on separate lines, so the combined
/// token `ghp_BODYBODYBODY01` is never synthesized.
#[test]
fn join_window_of_one_disables_joining() {
let text = "token = \"ghp_\" +\n\"BODYBODYBODY01\"";
let (out, original_end) = preprocess_with_window(text, 1);
assert_eq!(original_end, text.len());
assert!(
!out.contains("ghp_BODYBODYBODY01"),
"a join window of 1 must not reassemble across the continuation: {out:?}"
);
assert!(
out.starts_with(text),
"the original two-line body is still carried through verbatim"
);
}