Skip to content

Commit b24ab78

Browse files
committed
refactor(common): make filename redaction a shared utility
It was private to RecordFailure, which is the wrong home for a rule that belongs wherever a document name might be written down. policy_processed_files already stores the full path in plaintext, and that is the next caller. Moves the pattern to common as FilenameRedaction.attemptRedaction, named for what it is rather than what it guarantees, with the cases moved alongside it. RecordFailurePrivacyTest keeps one check that a stored message goes through it at all, which is the part that belongs to the failure package. Also stops storing the downstream description for a kind we recognise: we already have its own copy, so the raw text only adds somewhere for a name to hide. Unrecognised failures keep theirs, since it is the only thing telling a reviewer what went wrong.
1 parent 49baf64 commit b24ab78

6 files changed

Lines changed: 169 additions & 79 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package stirling.software.common.util;
2+
3+
import java.util.regex.Pattern;
4+
5+
/**
6+
* Removes anything shaped like a file name from free text, so a document's name is not persisted or
7+
* logged alongside whatever went wrong with it.
8+
*
9+
* <p>Best-effort by nature: this reads prose written elsewhere and guesses. Somewhere that can
10+
* avoid holding the name at all should do that instead and treat this as the backstop.
11+
*/
12+
public final class FilenameRedaction {
13+
14+
/** What a redacted name is replaced with. Recognisable, and short enough not to bloat a row. */
15+
public static final String PLACEHOLDER = "<file>";
16+
17+
/**
18+
* Name characters ending in one to three short extensions. Matched by shape, so a newly
19+
* supported format needs no maintenance here.
20+
*
21+
* <p>Spaces are crossed only when the name is delimited by a quote, bracket or path separator:
22+
* an undelimited one cannot be told from the sentence around it, and swallowing the sentence
23+
* costs more than it saves. An all-digit extension is not one, which keeps {@code v2.14.2}
24+
* intact, and the lookarounds keep it off dotted identifiers so {@code java.lang.Foo} survives
25+
* a stack trace.
26+
*/
27+
private static final Pattern FILE_NAME =
28+
Pattern.compile(
29+
"(?<![\\w.])(?:(?<=[\"'(\\[/\\\\])[\\p{L}\\p{N}_%+&'()\\[\\]\\-]+"
30+
+ "(?:[ ][\\p{L}\\p{N}_%+&'()\\[\\]\\-]+){0,6}"
31+
+ "|[\\p{L}\\p{N}_%+&'()\\[\\]\\-]+)"
32+
+ "(?:\\.(?![0-9]+(?:\\b|\\.))[\\p{L}\\p{N}]{1,8}){1,3}(?![\\w.])",
33+
Pattern.UNICODE_CHARACTER_CLASS);
34+
35+
private FilenameRedaction() {}
36+
37+
/**
38+
* {@code text} with every file name replaced by {@link #PLACEHOLDER}. Null in, null out, so a
39+
* caller with nothing to redact needs no branch of its own.
40+
*
41+
* <p>Best-effort: see the class note. Exactly what is and is not caught is pinned by {@code
42+
* FilenameRedactionTest}.
43+
*/
44+
public static String attemptRedaction(String text) {
45+
return text == null ? null : FILE_NAME.matcher(text).replaceAll(PLACEHOLDER);
46+
}
47+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package stirling.software.common.util;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.ValueSource;
9+
10+
/**
11+
* What the redaction does and does not catch. Best-effort by design, so these pin the boundary
12+
* rather than claim completeness: a case that is only partly redacted is asserted as such.
13+
*/
14+
class FilenameRedactionTest {
15+
16+
@ParameterizedTest
17+
@DisplayName("names are removed, whatever shape they come in")
18+
@ValueSource(
19+
strings = {
20+
"Failed on report (final).pdf",
21+
"Failed on \"Q3 Layoff List.pdf\"",
22+
"Failed on /srv/in/Q3 Layoff List.pdf",
23+
"Failed on severance-agreement.pdf.gz",
24+
"Failed on termination.tar.gz",
25+
"Failed on \u5c65\u6b74\u66f8.pdf",
26+
"Failed on \u043e\u0442\u0447\u0451\u0442-\u0437\u0430\u0440\u043f\u043b\u0430\u0442\u0430.pdf",
27+
"Failed on payslip%20march.pdf",
28+
"Failed on invoice_2024.pdf",
29+
"Failed on 'end of year (2024).xlsx'",
30+
"Failed on C:\\Users\\dana\\Q4 Report.docx",
31+
"Failed on Smith & Co - agreement.pdf",
32+
"Failed on ../tmp/upload.PDF",
33+
})
34+
void areRedacted(String text) {
35+
assertThat(FilenameRedaction.attemptRedaction(text))
36+
.doesNotContainIgnoringCase(".pdf")
37+
.doesNotContainIgnoringCase(".xlsx")
38+
.doesNotContainIgnoringCase(".docx")
39+
.contains(FilenameRedaction.PLACEHOLDER);
40+
}
41+
42+
@ParameterizedTest
43+
@DisplayName("text that only looks like a name is left alone")
44+
@ValueSource(
45+
strings = {
46+
"Policy run failed: java.lang.NullPointerException",
47+
"version v2.14.2 released",
48+
"The PDF Document is passworded",
49+
})
50+
void areLeftAlone(String text) {
51+
assertThat(FilenameRedaction.attemptRedaction(text)).isEqualTo(text);
52+
}
53+
54+
@Test
55+
@DisplayName("an undelimited spaced name is only partly removed")
56+
void anUndelimitedSpacedNameIsPartlyRedacted() {
57+
// The known gap. Crossing spaces without a delimiter would swallow the sentence too, and
58+
// "<file>" on its own tells a reader nothing about what failed.
59+
//
60+
// TODO: harden. This asserts current behaviour, not desired behaviour. Somewhere that can
61+
// avoid holding the name at all should do that rather than lean on this.
62+
String redacted = FilenameRedaction.attemptRedaction("Failed on Q3 Layoff List.pdf");
63+
64+
assertThat(redacted).doesNotContain(".pdf").doesNotContain("List");
65+
assertThat(redacted).as("a fragment survives, for now").contains("Q3 Layoff");
66+
}
67+
68+
@Test
69+
void nullInNullOut() {
70+
assertThat(FilenameRedaction.attemptRedaction(null)).isNull();
71+
}
72+
}

app/proprietary/src/main/java/stirling/software/proprietary/failure/PolicyFailureRecorder.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ public void recordRunFailureAs(
4848
record(kind, runId, policyId, actor, null, detail);
4949
}
5050

51+
/**
52+
* The downstream message is kept only for a failure we could not classify, where it is the one
53+
* thing telling a reviewer what went wrong. A recognised kind already carries its own copy, so
54+
* the raw text adds nothing except somewhere for a document name to hide.
55+
*/
56+
private static String diagnosticFor(FailureKind kind, String detail) {
57+
return kind == FailureKind.UNKNOWN ? detail : null;
58+
}
59+
5160
private void record(
5261
FailureKind kind,
5362
String runId,
@@ -58,7 +67,13 @@ private void record(
5867
try {
5968
store.record(
6069
RecordFailure.forRun(
61-
kind, teamFor(policyId), actor, policyId, runId, fileIdentity, detail));
70+
kind,
71+
teamFor(policyId),
72+
actor,
73+
policyId,
74+
runId,
75+
fileIdentity,
76+
diagnosticFor(kind, detail)));
6277
} catch (RuntimeException e) {
6378
// Deliberately swallowed: see the class comment.
6479
log.warn("Could not record failure event for run {} (kind {})", runId, kind.getId(), e);

app/proprietary/src/main/java/stirling/software/proprietary/failure/RecordFailure.java

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import java.security.MessageDigest;
55
import java.security.NoSuchAlgorithmException;
66
import java.util.HexFormat;
7-
import java.util.regex.Pattern;
7+
8+
import stirling.software.common.util.FilenameRedaction;
89

910
/**
1011
* Everything needed to record one failure. Every reference field is nullable, because a failure can
@@ -22,26 +23,6 @@ public record RecordFailure(
2223
String fileId,
2324
String detail) {
2425

25-
/**
26-
* Anything shaped like a file name: name characters ending in one to three short extensions.
27-
* Matched by shape, so a newly supported format needs no maintenance here.
28-
*
29-
* <p>Best-effort, and deliberately so in one place: spaces are crossed only when the name is
30-
* delimited, because an undelimited one cannot be told from the sentence around it. {@code
31-
* RecordFailurePrivacyTest} pins exactly what is and is not caught.
32-
*
33-
* <p>TODO: store the parsed Problem Details fields rather than the stringified exception, so
34-
* this is a backstop instead of the mechanism. We own the producer; we should not be reading
35-
* our own structured data back out of prose.
36-
*/
37-
private static final Pattern FILE_PATH_OR_NAME =
38-
Pattern.compile(
39-
"(?<![\\w.])(?:(?<=[\"'(\\[/\\\\])[\\p{L}\\p{N}_%+&'()\\[\\]\\-]+"
40-
+ "(?:[ ][\\p{L}\\p{N}_%+&'()\\[\\]\\-]+){0,6}"
41-
+ "|[\\p{L}\\p{N}_%+&'()\\[\\]\\-]+)"
42-
+ "(?:\\.(?![0-9]+(?:\\b|\\.))[\\p{L}\\p{N}]{1,8}){1,3}(?![\\w.])",
43-
Pattern.UNICODE_CHARACTER_CLASS);
44-
4526
/** Upper bound on a stored message, so one enormous stack trace cannot fill the column. */
4627
private static final int MAX_DETAIL_LENGTH = 2_000;
4728

@@ -54,7 +35,7 @@ public record RecordFailure(
5435
}
5536
// Sanitised here rather than at each call site, since this record is the only way a row is
5637
// written. Capped too: an unclassified failure carries a raw message of unbounded length.
57-
detail = truncate(withoutFileNames(detail));
38+
detail = truncate(FilenameRedaction.attemptRedaction(detail));
5839
}
5940

6041
/** A processor-side failure with no file or source context, e.g. a run that failed outright. */
@@ -113,14 +94,6 @@ public String dedupKey() {
11394
}
11495
}
11596

116-
/**
117-
* Strip file paths and names out of a failure message. The engine's own messages already omit
118-
* them; a message forwarded from a downstream tool is outside this package's control.
119-
*/
120-
private static String withoutFileNames(String detail) {
121-
return detail == null ? null : FILE_PATH_OR_NAME.matcher(detail).replaceAll("<file>");
122-
}
123-
12497
private static String truncate(String detail) {
12598
if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
12699
return detail;

app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,28 @@ void classifiesTheCauseAndStoresTheRunsOwnMessage() {
100100
assertThat(event.policyId()).isEqualTo("policy-1");
101101
assertThat(event.actor()).isEqualTo("dana@example.com");
102102
assertThat(event.origin()).isEqualTo(FailureOrigin.POLICY);
103-
// The run's message, not the exception's: that is what the operator saw.
104-
assertThat(event.detail()).isEqualTo("Policy run failed: locked");
103+
// A recognised kind carries its own copy, so the downstream text is dropped: it adds
104+
// nothing a reviewer needs and is where a document name would otherwise survive.
105+
assertThat(event.detail()).isNull();
106+
}
107+
108+
@Test
109+
void keepsTheMessageForAFailureItCouldNotClassify() {
110+
// The opposite case, and the reason the message is stored at all: with no kind to
111+
// describe it, this text is the only thing telling a reviewer what went wrong.
112+
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
113+
114+
recorder.recordRunFailure(
115+
"run-1",
116+
"policy-1",
117+
"dana@example.com",
118+
null,
119+
"Policy run failed: something we do not recognise",
120+
new RuntimeException("boom"));
121+
122+
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
123+
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
124+
assertThat(event.detail()).contains("something we do not recognise");
105125
}
106126

107127
@Test

app/proprietary/src/test/java/stirling/software/proprietary/failure/RecordFailurePrivacyTest.java

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -164,55 +164,18 @@ void neverCutsBetweenTheHalvesOfASurrogatePair() {
164164
}
165165

166166
@Nested
167-
@DisplayName("the shapes a document name actually takes")
168-
class RealisticNames {
169-
170-
/** Raised in review: the first pass only handled a single hyphenated ASCII token. */
171-
@ParameterizedTest
172-
@ValueSource(
173-
strings = {
174-
"Failed on report (final).pdf",
175-
"Failed on \"Q3 Layoff List.pdf\"",
176-
"Failed on /srv/in/Q3 Layoff List.pdf",
177-
"Failed on severance-agreement.pdf.gz",
178-
"Failed on termination.tar.gz",
179-
"Failed on \u5c65\u6b74\u66f8.pdf",
180-
"Failed on \u043e\u0442\u0447\u0451\u0442-\u0437\u0430\u0440\u043f\u043b\u0430\u0442\u0430.pdf",
181-
"Failed on payslip%20march.pdf",
182-
"Failed on invoice_2024.pdf",
183-
"Failed on 'end of year (2024).xlsx'",
184-
"Failed on C:\\Users\\dana\\Q4 Report.docx",
185-
"Failed on Smith & Co - agreement.pdf",
186-
"Failed on ../tmp/upload.PDF",
187-
})
188-
void areRedacted(String message) {
189-
assertThat(withDetail(message).detail()).doesNotContain(".pdf").contains("<file>");
190-
}
167+
@DisplayName("redaction is applied on the way in")
168+
class Redaction {
191169

170+
/**
171+
* Which shapes are caught is {@code FilenameRedactionTest}'s job, in the module that owns
172+
* the rule. All this needs to know is that no row is written without it.
173+
*/
192174
@Test
193-
void anUndelimitedSpacedNameIsOnlyPartlyRedacted() {
194-
// The deliberate limit: crossing spaces without a delimiter would swallow the sentence
195-
// too, and "<file>" alone tells a reviewer nothing about what failed. So the extension
196-
// and the token carrying it go, and the leading words stay.
197-
//
198-
// TODO: harden. This asserts a known gap rather than desired behaviour — "Q3 Layoff"
199-
// is still a fragment of a real document name. When the server stops forwarding a
200-
// downstream tool's message verbatim, this should assert full redaction instead.
201-
String stored = withDetail("Failed on Q3 Layoff List.pdf").detail();
202-
203-
assertThat(stored).doesNotContain(".pdf").doesNotContain("List");
204-
assertThat(stored).as("a fragment of the name survives, for now").contains("Q3 Layoff");
205-
}
175+
void everyStoredMessageGoesThroughIt() {
176+
String stored = withDetail("Failed on \"Q3 Layoff List.pdf\"").detail();
206177

207-
@ParameterizedTest
208-
@ValueSource(
209-
strings = {
210-
"Policy run failed: java.lang.NullPointerException",
211-
"version v2.14.2 released",
212-
"The PDF Document is passworded",
213-
})
214-
void areLeftAlone(String message) {
215-
assertThat(withDetail(message).detail()).isEqualTo(message);
178+
assertThat(stored).doesNotContain("Q3 Layoff List.pdf").contains("<file>");
216179
}
217180
}
218181
}

0 commit comments

Comments
 (0)