Skip to content
Draft
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@
**Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key.
**Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct.
**Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`).
## 2025-05-24 - [CRITICAL] Fail-closed Security Policy Files (.html4ignore)
**Vulnerability:** TOCTOU and Policy bypass via broken/invalid .html4ignore symlinks or directories.
**Learning:** Security policy files (like .html4ignore) were previously ignoring unreadable or invalid variants (e.g. symlinks, directories, unreadable files). This meant that if a policy file couldn't be read (due to TOCTOU manipulation or broken symlinks), the application failed open and generated the index anyway, potentially exposing sensitive files that should have been excluded.
**Prevention:** Implement `IgnoreFileReadException` and fail-closed architecture. When encountering a security policy file, check if it exists or is a symlink first, then explicitly verify it's a valid readable file. If invalid/unreadable, throw the exception to abruptly stop traversal (suppress publication) for that specific directory subtree.
11 changes: 11 additions & 0 deletions parse_jacoco.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import csv

try:
with open("build/reports/jacoco/test/jacocoTestReport.csv", "r") as f:
reader = csv.DictReader(f)
for row in reader:
if int(row["INSTRUCTION_MISSED"]) > 0:
total = int(row["INSTRUCTION_COVERED"]) + int(row["INSTRUCTION_MISSED"])
print(f"{row['PACKAGE']}.{row['CLASS']}.{row['METHOD']}: Missed {row['INSTRUCTION_MISSED']}/{total} instructions")
except Exception as e:
print(f"Error: {e}")
49 changes: 35 additions & 14 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,27 @@ internal fun crawl_directories(
val dirFilesNames = dirFiles?.let { files ->
Array(files.size) { index -> files[index].name }
}
val exclude = processIgnoreFile(lle.file, dirFilesNames)

if(maxLevel == -1 || currentLevel <= maxLevel)
processDirectory(lle.file, exclude, dirFiles)

if(maxLevel == -1 || currentLevel < maxLevel) {
dirFiles?.forEach {
// ⚡ Bolt Performance Optimization: Short-circuit OS stat calls
// by checking cheap in-memory string exclusion rules first
if(!it.name.isHiddenFile() && it.name !in exclude) {
val childAttrs = readAttributes(it)
if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
ll.push(childEntry)

val exclude = try {
processIgnoreFile(lle.file, dirFilesNames)
} catch (e: IgnoreFileReadException) {
null
}

if (exclude != null) {
if(maxLevel == -1 || currentLevel <= maxLevel)
processDirectory(lle.file, exclude, dirFiles)

if(maxLevel == -1 || currentLevel < maxLevel) {
dirFiles?.forEach {
// ⚡ Bolt Performance Optimization: Short-circuit OS stat calls
// by checking cheap in-memory string exclusion rules first
if(!it.name.isHiddenFile() && it.name !in exclude) {
val childAttrs = readAttributes(it)
if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) {
val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key)
ll.push(childEntry)
}
}
}
}
Expand Down Expand Up @@ -293,6 +300,13 @@ fun String.urlEncodePath(): String {
return encoded?.toString() ?: this
}

/**
* Exception thrown when a security policy file (e.g., .html4ignore) is detected
* but cannot be read or disappears (TOCTOU race condition).
* Enforces fail-closed behavior to suppress directory publication and traversal.
*/
class IgnoreFileReadException(message: String) : Exception(message)

fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): Set<String> {

val ignore_filename = ".html4ignore"
Expand All @@ -303,6 +317,13 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array<String>? = null): S

val files_to_exclude = mutableSetOf<String>()

val ignoreExists = ignore_file.exists() || Files.isSymbolicLink(ignore_file.toPath())
if (ignoreExists) {
if (!ignore_file.isFile || Files.isSymbolicLink(ignore_file.toPath()) || !ignore_file.canRead()) {
throw IgnoreFileReadException("Policy file $ignore_filename is inaccessible or invalid. Failing closed to prevent TOCTOU bypass.")
}
}

// 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다.
// 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지
// 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인
Expand Down
38 changes: 31 additions & 7 deletions src/test/kotlin/html4tree/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,29 @@ class MainTest {
val ignoreDir = File(tempDir, ".html4ignore")
ignoreDir.mkdir()

// This should not crash or parse the directory
val excluded = process_ignore_file(tempDir, null)
assertTrue(excluded.contains("index.html"))
// This should fail-closed and throw IgnoreFileReadException
var thrown = false
try {
process_ignore_file(tempDir, null)
} catch (e: IgnoreFileReadException) {
thrown = true
}
assertTrue(thrown, "Expected IgnoreFileReadException to be thrown")
}

@Test
fun testCrawlDirectoriesIgnoreFileReadException() {
val ignoreDir = File(tempDir, ".html4ignore")
ignoreDir.mkdir()
val ll = LinkedList()
val topEntry = LinkedListEntry(tempDir, 0, read_file_identity(tempDir).key)
ll.push(topEntry)

crawl_directories(
ll = ll,
maxLevel = -1,
processIgnoreFile = { _, _ -> throw IgnoreFileReadException("mock") }
)
}

@Test
Expand Down Expand Up @@ -762,10 +782,14 @@ class MainTest {

File(tempDir, "test.txt").createNewFile()

// Should ignore the symlink and NOT parse it
val excluded = process_ignore_file(tempDir, null)
assertFalse(excluded.contains("test.txt"))
assertTrue(excluded.contains("index.html"))
// Should fail-closed and throw IgnoreFileReadException
var thrown = false
try {
process_ignore_file(tempDir, null)
} catch (e: IgnoreFileReadException) {
thrown = true
}
assertTrue(thrown, "Expected IgnoreFileReadException to be thrown")
}

@Test
Expand Down
Loading