Skip to content

Commit ac97db0

Browse files
feat: add GuardLinker to create PROTECTS edges between GUARD/MIDDLEWARE and ENDPOINT nodes
Implements RAN-61. GuardLinker uses file-path proximity (same file = match) to infer that guards and middleware in a file protect endpoints in that file. This surfaces security architecture in the graph for Spring @PreAuthorize, @secured, DjangoAuth, FastAPIAuth, NestJSGuards, and generic middleware nodes. - 9 unit tests: positive match, middleware, class-level, cross-file negative, no-guards, no-endpoints, duplicate avoidance, null filePath, determinism - 1468 total tests pass, 0 failures Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent ed2c587 commit ac97db0

2 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.github.randomcodespace.iq.analyzer.linker;
2+
3+
import io.github.randomcodespace.iq.model.CodeEdge;
4+
import io.github.randomcodespace.iq.model.CodeNode;
5+
import io.github.randomcodespace.iq.model.EdgeKind;
6+
import io.github.randomcodespace.iq.model.NodeKind;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.ArrayList;
12+
import java.util.HashSet;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.Set;
16+
import java.util.TreeMap;
17+
import java.util.TreeSet;
18+
19+
/**
20+
* Links GUARD and MIDDLEWARE nodes to ENDPOINT nodes via PROTECTS edges.
21+
* <p>
22+
* Uses file-path proximity as the matching heuristic: a guard or middleware
23+
* in the same file as an endpoint is assumed to protect that endpoint.
24+
* This correctly handles class-level Spring Security annotations
25+
* (@PreAuthorize, @Secured on a class) which appear in the same file as
26+
* the endpoint methods they protect.
27+
*/
28+
@Component
29+
public class GuardLinker implements Linker {
30+
31+
private static final Logger log = LoggerFactory.getLogger(GuardLinker.class);
32+
33+
@Override
34+
public LinkResult link(List<CodeNode> nodes, List<CodeEdge> edges) {
35+
// Group guards/middlewares and endpoints by filePath
36+
Map<String, List<CodeNode>> guardsByFile = new TreeMap<>();
37+
Map<String, List<CodeNode>> endpointsByFile = new TreeMap<>();
38+
39+
for (CodeNode node : nodes) {
40+
String fp = node.getFilePath();
41+
if (fp == null || fp.isBlank()) continue;
42+
43+
if (node.getKind() == NodeKind.GUARD || node.getKind() == NodeKind.MIDDLEWARE) {
44+
guardsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node);
45+
} else if (node.getKind() == NodeKind.ENDPOINT) {
46+
endpointsByFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(node);
47+
}
48+
}
49+
50+
if (guardsByFile.isEmpty() || endpointsByFile.isEmpty()) {
51+
return LinkResult.empty();
52+
}
53+
54+
// Collect existing PROTECTS edges to avoid duplicates
55+
Set<String> existingProtects = new HashSet<>();
56+
for (CodeEdge edge : edges) {
57+
if (edge.getKind() == EdgeKind.PROTECTS && edge.getTarget() != null) {
58+
existingProtects.add(edge.getSourceId() + "->" + edge.getTarget().getId());
59+
}
60+
}
61+
62+
List<CodeEdge> newEdges = new ArrayList<>();
63+
64+
// Same-file matching: each guard protects all endpoints in the same file
65+
for (String filePath : new TreeSet<>(guardsByFile.keySet())) {
66+
List<CodeNode> fileEndpoints = endpointsByFile.get(filePath);
67+
if (fileEndpoints == null || fileEndpoints.isEmpty()) continue;
68+
69+
List<CodeNode> fileGuards = guardsByFile.get(filePath);
70+
for (CodeNode guard : fileGuards) {
71+
for (CodeNode endpoint : fileEndpoints) {
72+
String key = guard.getId() + "->" + endpoint.getId();
73+
if (!existingProtects.contains(key)) {
74+
var edge = new CodeEdge();
75+
edge.setId("guard-link:" + guard.getId() + "->" + endpoint.getId());
76+
edge.setKind(EdgeKind.PROTECTS);
77+
edge.setSourceId(guard.getId());
78+
edge.setTarget(endpoint);
79+
edge.setProperties(Map.of("inferred", true));
80+
newEdges.add(edge);
81+
existingProtects.add(key);
82+
}
83+
}
84+
}
85+
}
86+
87+
if (!newEdges.isEmpty()) {
88+
log.debug("GuardLinker created {} PROTECTS edges", newEdges.size());
89+
}
90+
return LinkResult.ofEdges(newEdges);
91+
}
92+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package io.github.randomcodespace.iq.analyzer.linker;
2+
3+
import io.github.randomcodespace.iq.model.CodeEdge;
4+
import io.github.randomcodespace.iq.model.CodeNode;
5+
import io.github.randomcodespace.iq.model.EdgeKind;
6+
import io.github.randomcodespace.iq.model.NodeKind;
7+
import org.junit.jupiter.api.Test;
8+
9+
import java.util.List;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
13+
class GuardLinkerTest {
14+
15+
private final GuardLinker linker = new GuardLinker();
16+
17+
private CodeNode guardNode(String id, String filePath) {
18+
var node = new CodeNode(id, NodeKind.GUARD, id);
19+
node.setFilePath(filePath);
20+
return node;
21+
}
22+
23+
private CodeNode middlewareNode(String id, String filePath) {
24+
var node = new CodeNode(id, NodeKind.MIDDLEWARE, id);
25+
node.setFilePath(filePath);
26+
return node;
27+
}
28+
29+
private CodeNode endpointNode(String id, String filePath) {
30+
var node = new CodeNode(id, NodeKind.ENDPOINT, id);
31+
node.setFilePath(filePath);
32+
return node;
33+
}
34+
35+
@Test
36+
void linksGuardToEndpointInSameFile() {
37+
var guard = guardNode("auth:UserController.java:Secured:10", "UserController.java");
38+
var endpoint = endpointNode("ep:UserController.java:GET:/users:20", "UserController.java");
39+
40+
LinkResult result = linker.link(List.of(guard, endpoint), List.of());
41+
42+
assertEquals(1, result.edges().size());
43+
CodeEdge edge = result.edges().getFirst();
44+
assertEquals(EdgeKind.PROTECTS, edge.getKind());
45+
assertEquals(guard.getId(), edge.getSourceId());
46+
assertEquals(endpoint.getId(), edge.getTarget().getId());
47+
assertEquals(true, edge.getProperties().get("inferred"));
48+
}
49+
50+
@Test
51+
void linksMiddlewareToEndpointInSameFile() {
52+
var middleware = middlewareNode("mw:routes.ts:authMiddleware:5", "routes.ts");
53+
var endpoint = endpointNode("ep:routes.ts:GET:/profile:15", "routes.ts");
54+
55+
LinkResult result = linker.link(List.of(middleware, endpoint), List.of());
56+
57+
assertEquals(1, result.edges().size());
58+
assertEquals(EdgeKind.PROTECTS, result.edges().getFirst().getKind());
59+
}
60+
61+
@Test
62+
void classLevelGuardProtectsAllEndpointsInSameFile() {
63+
var guard = guardNode("auth:OrderController.java:PreAuthorize:3", "OrderController.java");
64+
var ep1 = endpointNode("ep:OrderController.java:GET:/orders:25", "OrderController.java");
65+
var ep2 = endpointNode("ep:OrderController.java:POST:/orders:35", "OrderController.java");
66+
var ep3 = endpointNode("ep:OrderController.java:DELETE:/orders/{id}:45", "OrderController.java");
67+
68+
LinkResult result = linker.link(List.of(guard, ep1, ep2, ep3), List.of());
69+
70+
assertEquals(3, result.edges().size());
71+
assertTrue(result.edges().stream().allMatch(e -> e.getKind() == EdgeKind.PROTECTS));
72+
assertTrue(result.edges().stream().allMatch(e -> e.getSourceId().equals(guard.getId())));
73+
}
74+
75+
@Test
76+
void guardInDifferentFileDoesNotProtectEndpoint() {
77+
var guard = guardNode("auth:SecurityConfig.java:EnableWebSecurity:1", "SecurityConfig.java");
78+
var endpoint = endpointNode("ep:UserController.java:GET:/users:10", "UserController.java");
79+
80+
LinkResult result = linker.link(List.of(guard, endpoint), List.of());
81+
82+
assertTrue(result.edges().isEmpty());
83+
}
84+
85+
@Test
86+
void noGuardsReturnsEmpty() {
87+
var endpoint = endpointNode("ep:UserController.java:GET:/users:10", "UserController.java");
88+
89+
LinkResult result = linker.link(List.of(endpoint), List.of());
90+
91+
assertTrue(result.edges().isEmpty());
92+
}
93+
94+
@Test
95+
void noEndpointsReturnsEmpty() {
96+
var guard = guardNode("auth:UserController.java:Secured:5", "UserController.java");
97+
98+
LinkResult result = linker.link(List.of(guard), List.of());
99+
100+
assertTrue(result.edges().isEmpty());
101+
}
102+
103+
@Test
104+
void avoidsDuplicateEdges() {
105+
var guard = guardNode("auth:UserController.java:Secured:5", "UserController.java");
106+
var endpoint = endpointNode("ep:UserController.java:GET:/users:15", "UserController.java");
107+
108+
var existing = new CodeEdge();
109+
existing.setId("existing");
110+
existing.setKind(EdgeKind.PROTECTS);
111+
existing.setSourceId(guard.getId());
112+
existing.setTarget(endpoint);
113+
114+
LinkResult result = linker.link(List.of(guard, endpoint), List.of(existing));
115+
116+
assertTrue(result.edges().isEmpty());
117+
}
118+
119+
@Test
120+
void nodesWithNullFilePathAreIgnored() {
121+
var guard = new CodeNode("auth:guard:1", NodeKind.GUARD, "guard");
122+
// filePath is null by default
123+
var endpoint = endpointNode("ep:file.java:GET:/users:10", "file.java");
124+
125+
LinkResult result = linker.link(List.of(guard, endpoint), List.of());
126+
127+
assertTrue(result.edges().isEmpty());
128+
}
129+
130+
@Test
131+
void determinismRunTwiceProducesSameResult() {
132+
var guard1 = guardNode("auth:Ctrl.java:Secured:5", "Ctrl.java");
133+
var guard2 = guardNode("auth:Ctrl.java:PreAuthorize:8", "Ctrl.java");
134+
var ep1 = endpointNode("ep:Ctrl.java:GET:/a:20", "Ctrl.java");
135+
var ep2 = endpointNode("ep:Ctrl.java:POST:/b:30", "Ctrl.java");
136+
137+
LinkResult r1 = linker.link(List.of(guard1, guard2, ep1, ep2), List.of());
138+
LinkResult r2 = linker.link(List.of(guard1, guard2, ep1, ep2), List.of());
139+
140+
assertEquals(r1.edges().size(), r2.edges().size());
141+
for (int i = 0; i < r1.edges().size(); i++) {
142+
assertEquals(r1.edges().get(i).getId(), r2.edges().get(i).getId());
143+
}
144+
}
145+
}

0 commit comments

Comments
 (0)