feat: add AI-based security suggestion engine and CLI integration

Introduce new package org.egothor.methodatlas.ai providing AI-assisted
classification of JUnit tests for security relevance.

Key changes:
- add AI suggestion engine, provider abstraction, and provider clients
  (OpenAI-compatible, Ollama, Anthropic)
- implement strict JSON prompt/response contract and taxonomy handling
- integrate AI enrichment into MethodAtlas CLI output (CSV and plain
  modes)
- add configuration via AiOptions and CLI flags
- add comprehensive JUnit + Mockito test coverage for AI components
  and CLI integration scenarios
- add realistic test fixtures for security-related test classes
- update Gradle configuration for Mockito agent support on JDK 21+
- provide complete Javadoc for the AI module

The AI layer is optional and degrades gracefully when providers
are unavailable or responses fail.
This commit is contained in:
2026-03-08 23:44:55 +01:00
parent d3a8270d8a
commit bbb6adb7e5
36 changed files with 6037 additions and 283 deletions

View File

@@ -0,0 +1,70 @@
package com.acme.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class AccessControlServiceTest {
@Test
@Tag("security")
@Tag("authz")
void shouldAllowOwnerToReadOwnStatement() {
String userId = "user-100";
String ownerId = "user-100";
boolean allowed = userId.equals(ownerId);
assertEquals(true, allowed);
}
@Test
@Tag("security")
@Tag("authz")
void shouldAllowAdministratorToReadAnyStatement() {
String role = "ADMIN";
boolean allowed = "ADMIN".equals(role);
assertEquals(true, allowed);
}
@Test
@Tag("security")
@Tag("authz")
void shouldDenyForeignUserFromReadingAnotherUsersStatement() {
String requesterId = "user-200";
String ownerId = "user-100";
boolean allowed = requesterId.equals(ownerId);
assertEquals(false, allowed);
}
@Test
@Tag("security")
@Tag("authn")
void shouldRejectUnauthenticatedRequest() {
String principal = null;
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
if (principal == null) {
throw new IllegalStateException("Unauthenticated request");
}
});
assertEquals("Unauthenticated request", ex.getMessage());
}
@Test
void shouldRenderFriendlyAccountLabel() {
String firstName = "Ada";
String lastName = "Lovelace";
String label = firstName + " " + lastName;
assertEquals("Ada Lovelace", label);
}
}

View File

@@ -0,0 +1,53 @@
package com.acme.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class AuditLoggingTest {
@Test
@Tag("security")
@Tag("audit")
void shouldWriteAuditEventForPrivilegeChange() {
String actor = "admin-7";
String action = "GRANT_ROLE";
String target = "user-17";
String auditLine = "actor=" + actor + ";action=" + action + ";target=" + target;
assertEquals("actor=admin-7;action=GRANT_ROLE;target=user-17", auditLine);
}
@Test
@Tag("security")
@Tag("logging")
void shouldNotLogRawBearerToken() {
String token = "Bearer eyJhbGciOiJIUzI1NiJ9.secret.signature";
String logLine = "Authorization header redacted";
assertFalse(logLine.contains(token));
assertEquals("Authorization header redacted", logLine);
}
@Test
@Tag("security")
@Tag("logging")
void shouldNotLogPlaintextPasswordOnAuthenticationFailure() {
String password = "Sup3rSecret!";
String logLine = "Authentication failed for user alice";
assertFalse(logLine.contains(password));
assertEquals("Authentication failed for user alice", logLine);
}
@Test
void shouldFormatHumanReadableSupportMessage() {
String user = "alice";
String message = "Support ticket opened for " + user;
assertEquals("Support ticket opened for alice", message);
}
}

View File

@@ -0,0 +1,63 @@
package com.acme.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
public class PathTraversalValidationTest {
@Test
@Tag("security")
@Tag("validation")
void shouldRejectRelativePathTraversalSequence() {
String userInput = "../secrets.txt";
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
if (userInput.contains("..")) {
throw new IllegalArgumentException("Path traversal attempt detected");
}
});
assertEquals("Path traversal attempt detected", ex.getMessage());
}
@Test
@Tag("security")
@Tag("validation")
void shouldRejectNestedTraversalAfterNormalization() {
String userInput = "reports/../../admin/keys.txt";
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
if (!normalized.startsWith(Path.of("/srv/app/uploads"))) {
throw new IllegalArgumentException("Escaped upload root");
}
});
assertEquals("Escaped upload root", ex.getMessage());
}
@Test
@Tag("security")
@Tag("validation")
void shouldAllowSafePathInsideUploadRoot() {
String userInput = "reports/2026/statement.pdf";
Path normalized = Path.of("/srv/app/uploads").resolve(userInput).normalize();
boolean allowed = normalized.startsWith(Path.of("/srv/app/uploads"));
assertEquals(true, allowed);
}
@Test
void shouldBuildDownloadFileName() {
String accountId = "ACC-42";
String fileName = accountId + "-statement.pdf";
assertEquals("ACC-42-statement.pdf", fileName);
}
}