Skip to content

Commit 5d9300a

Browse files
authored
Add Istio VirtualService specification analyzer (#1729)
Parses `networking.istio.io/v1` VirtualService manifests and emits an endpoint for each `spec.http[].match[].uri` entry. URI matchers may be `exact`, `prefix`, or `regex` and the matcher type is preserved as the `virtualservice-path-type` tag. Method matchers carry the same `exact|prefix|regex` shape; the extracted verb populates the endpoint method, falling back to `ANY` when no method is declared. `rewrite.uri` filters emit the rewritten path as an additional endpoint tagged with `virtualservice-source: rewrite`. Closes #1667
1 parent ca814be commit 5d9300a

12 files changed

Lines changed: 347 additions & 0 deletions

File tree

docs/content/usage/supported/specification/index.ko.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ sort_by = "weight"
3535
| HAR | JSON | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
3636
| Insomnia | JSON | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
3737
| Insomnia | YAML | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
38+
| Istio VirtualService | YAML | ☑️ | ☑️ ||||||
3839
| Kong | YAML | ☑️ | ☑️ ||||||
3940
| Kubernetes Gateway API (HTTPRoute) | YAML | ☑️ | ☑️ ||||||
4041
| Kubernetes Ingress | YAML | ☑️ | ☑️ ||||||

docs/content/usage/supported/specification/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Beyond source code analysis, Noir can parse API and data specification formats s
3535
| HAR | JSON | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
3636
| Insomnia | JSON | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
3737
| Insomnia | YAML | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
38+
| Istio VirtualService | YAML | ☑️ | ☑️ ||||||
3839
| Kong | YAML | ☑️ | ☑️ ||||||
3940
| Kubernetes Gateway API (HTTPRoute) | YAML | ☑️ | ☑️ ||||||
4041
| Kubernetes Ingress | YAML | ☑️ | ☑️ ||||||

scripts/generate_supported_docs.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ SPEC_FRIENDLY_NAMES = {
3939
"aws_cloudformation" => "AWS SAM / CloudFormation",
4040
"azure_functions" => "Azure Functions",
4141
"cloudflare_wrangler" => "Cloudflare Workers (wrangler)",
42+
"istio_virtualservice" => "Istio VirtualService",
4243
"k8s_gateway_api" => "Kubernetes Gateway API (HTTPRoute)",
4344
"k8s_ingress" => "Kubernetes Ingress",
4445
"serverless_framework" => "Serverless Framework",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
apiVersion: networking.istio.io/v1
2+
kind: VirtualService
3+
metadata:
4+
name: api-vs
5+
spec:
6+
hosts:
7+
- api.example.com
8+
http:
9+
- match:
10+
- uri:
11+
prefix: /v1/users
12+
method:
13+
exact: GET
14+
- uri:
15+
exact: /v1/users
16+
method:
17+
exact: POST
18+
route:
19+
- destination:
20+
host: users.default.svc.cluster.local
21+
- match:
22+
- uri:
23+
regex: /v2/.*
24+
rewrite:
25+
uri: /v1/legacy
26+
route:
27+
- destination:
28+
host: legacy.default.svc.cluster.local
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
require "../../func_spec.cr"
2+
3+
expected_endpoints = [
4+
Endpoint.new("/v1/users", "GET"),
5+
Endpoint.new("/v1/users", "POST"),
6+
Endpoint.new("/v2/.*", "ANY"),
7+
Endpoint.new("/v1/legacy", "ANY"),
8+
]
9+
10+
FunctionalTester.new("fixtures/specification/istio_virtualservice/", {
11+
:techs => 1,
12+
:endpoints => expected_endpoints.size,
13+
}, expected_endpoints).perform_tests
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
require "../../../spec_helper"
2+
require "../../../../src/models/code_locator"
3+
require "../../../../src/analyzer/analyzers/specification/istio_virtualservice"
4+
5+
private def analyze_vs(content : String)
6+
path = File.tempname("virtualservice", ".yaml")
7+
File.write(path, content)
8+
locator = CodeLocator.instance
9+
locator.clear "istio-virtualservice-spec"
10+
locator.push "istio-virtualservice-spec", path
11+
12+
options = create_test_options
13+
analyzer = Analyzer::Specification::IstioVirtualservice.new options
14+
analyzer.analyze
15+
ensure
16+
File.delete(path) if path && File.exists?(path)
17+
end
18+
19+
private def tag_descriptions(endpoint : Endpoint, name : String) : Array(String)
20+
endpoint.tags.select { |t| t.name == name }.map(&.description)
21+
end
22+
23+
describe "Istio VirtualService Analyzer" do
24+
it "extracts uri matches with method" do
25+
endpoints = analyze_vs <<-YAML
26+
apiVersion: networking.istio.io/v1
27+
kind: VirtualService
28+
metadata: {name: api-vs}
29+
spec:
30+
hosts: ["api.example.com"]
31+
http:
32+
- match:
33+
- uri: {prefix: /v1/users}
34+
method: {exact: GET}
35+
- uri: {exact: /v1/users}
36+
method: {exact: POST}
37+
YAML
38+
39+
endpoints.map { |e| {e.url, e.method} }.sort!.should eq([
40+
{"/v1/users", "GET"},
41+
{"/v1/users", "POST"},
42+
])
43+
endpoints.each { |e| tag_descriptions(e, "virtualservice-host").should eq ["api.example.com"] }
44+
end
45+
46+
it "emits rewrite uri as additional endpoint" do
47+
endpoints = analyze_vs <<-YAML
48+
apiVersion: networking.istio.io/v1
49+
kind: VirtualService
50+
metadata: {name: vs}
51+
spec:
52+
http:
53+
- match:
54+
- uri: {regex: "/v2/.*"}
55+
rewrite: {uri: /v1/}
56+
YAML
57+
58+
endpoints.map(&.url).sort!.should eq ["/v1/", "/v2/.*"]
59+
rewritten = endpoints.find!(&.url.==("/v1/"))
60+
tag_descriptions(rewritten, "virtualservice-source").should eq ["rewrite"]
61+
regex = endpoints.find!(&.url.==("/v2/.*"))
62+
tag_descriptions(regex, "virtualservice-path-type").should eq ["regex"]
63+
end
64+
65+
it "defaults method to ANY when not declared" do
66+
endpoints = analyze_vs <<-YAML
67+
apiVersion: networking.istio.io/v1
68+
kind: VirtualService
69+
metadata: {name: vs}
70+
spec:
71+
http:
72+
- match:
73+
- uri: {prefix: /open}
74+
YAML
75+
76+
endpoints.size.should eq 1
77+
endpoints[0].method.should eq "ANY"
78+
end
79+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
require "../../../spec_helper"
2+
require "../../../../src/detector/detectors/specification/*"
3+
require "../../../../src/models/code_locator"
4+
5+
describe "Detect Istio VirtualService manifest" do
6+
options = create_test_options
7+
instance = Detector::Specification::IstioVirtualservice.new options
8+
9+
vs = <<-YAML
10+
apiVersion: networking.istio.io/v1
11+
kind: VirtualService
12+
metadata:
13+
name: api-vs
14+
spec:
15+
hosts: ["api.example.com"]
16+
http:
17+
- match:
18+
- uri:
19+
prefix: /v1/users
20+
method:
21+
exact: GET
22+
YAML
23+
24+
it "detects VirtualService manifest" do
25+
locator = CodeLocator.instance
26+
locator.clear "istio-virtualservice-spec"
27+
28+
instance.detect("mesh/api.yaml", vs).should be_true
29+
locator.all("istio-virtualservice-spec").should eq ["mesh/api.yaml"]
30+
end
31+
32+
it "rejects non-VirtualService Istio resources" do
33+
src = <<-YAML
34+
apiVersion: networking.istio.io/v1
35+
kind: DestinationRule
36+
YAML
37+
instance.detect("dr.yaml", src).should be_false
38+
end
39+
end

src/analyzer/analyzer.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def initialize_analyzers(logger : NoirLogger)
109109
{"oas2", Specification::Oas2},
110110
{"oas3", Specification::Oas3},
111111
{"insomnia", Specification::Insomnia},
112+
{"istio_virtualservice", Specification::IstioVirtualservice},
112113
{"mitmproxy", Specification::Mitmproxy},
113114
{"netlify", Specification::Netlify},
114115
{"odata", Specification::OData},
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
require "../../../models/analyzer"
2+
3+
module Analyzer::Specification
4+
class IstioVirtualservice < Analyzer
5+
METHOD_ANY = "ANY"
6+
7+
def analyze
8+
spec_files = CodeLocator.instance.all("istio-virtualservice-spec")
9+
return @result unless spec_files.is_a?(Array(String))
10+
11+
spec_files.each do |path|
12+
next unless File.exists?(path)
13+
14+
details = Details.new(PathInfo.new(path))
15+
content = read_file_content(path)
16+
begin
17+
YAML.parse_all(content).each { |doc| process_doc(doc, details) }
18+
rescue e
19+
@logger.debug "Exception processing #{path}"
20+
@logger.debug_sub e
21+
end
22+
end
23+
24+
@result
25+
end
26+
27+
private def process_doc(doc : YAML::Any, details : Details)
28+
root = doc.as_h?
29+
return unless root
30+
31+
kind = root[YAML::Any.new("kind")]?.try(&.as_s?)
32+
return unless kind == "VirtualService"
33+
34+
api_version = root[YAML::Any.new("apiVersion")]?.try(&.as_s?)
35+
return unless api_version && api_version.starts_with?("networking.istio.io/")
36+
37+
spec = root[YAML::Any.new("spec")]?.try(&.as_h?)
38+
return unless spec
39+
40+
hosts = collect_string_array(spec[YAML::Any.new("hosts")]?)
41+
42+
http_rules = spec[YAML::Any.new("http")]?.try(&.as_a?) || [] of YAML::Any
43+
http_rules.each { |rule| process_http_rule(rule, hosts, details) }
44+
end
45+
46+
private def collect_string_array(node : YAML::Any?) : Array(String)
47+
result = [] of String
48+
return result if node.nil?
49+
arr = node.as_a?
50+
return result unless arr
51+
arr.each do |entry|
52+
if str = entry.as_s?
53+
result << str unless str.empty?
54+
end
55+
end
56+
result
57+
end
58+
59+
private def process_http_rule(rule : YAML::Any, hosts : Array(String), details : Details)
60+
rule_h = rule.as_h?
61+
return unless rule_h
62+
63+
matches = rule_h[YAML::Any.new("match")]?.try(&.as_a?) || [] of YAML::Any
64+
rewrite_target = rule_h[YAML::Any.new("rewrite")]?.try(&.as_h?)
65+
.try(&.[YAML::Any.new("uri")]?)
66+
.try(&.as_s?)
67+
68+
matches.each { |match| process_match(match, hosts, rewrite_target, details) }
69+
end
70+
71+
private def process_match(match : YAML::Any, hosts : Array(String), rewrite_target : String?, details : Details)
72+
match_h = match.as_h?
73+
return unless match_h
74+
75+
uri = match_h[YAML::Any.new("uri")]?
76+
method_node = match_h[YAML::Any.new("method")]?
77+
78+
path, path_type = extract_uri_match(uri)
79+
return if path.nil? || path.empty?
80+
81+
method = extract_string_match(method_node) || METHOD_ANY
82+
method = method.upcase
83+
84+
emit_endpoint(path, method, path_type, hosts, "match", details)
85+
86+
if rewrite_target && !rewrite_target.empty? && rewrite_target != path
87+
emit_endpoint(rewrite_target, method, path_type, hosts, "rewrite", details)
88+
end
89+
end
90+
91+
private def extract_uri_match(node : YAML::Any?) : Tuple(String?, String)
92+
return {nil, "prefix"} if node.nil?
93+
h = node.as_h?
94+
return {nil, "prefix"} unless h
95+
{"exact", "prefix", "regex"}.each do |kind|
96+
value = h[YAML::Any.new(kind)]?.try(&.as_s?)
97+
return {value, kind} if value && !value.empty?
98+
end
99+
{nil, "prefix"}
100+
end
101+
102+
private def extract_string_match(node : YAML::Any?) : String?
103+
return if node.nil?
104+
if str = node.as_s?
105+
return str.empty? ? nil : str
106+
end
107+
h = node.as_h?
108+
return unless h
109+
{"exact", "prefix", "regex"}.each do |kind|
110+
value = h[YAML::Any.new(kind)]?.try(&.as_s?)
111+
return value if value && !value.empty?
112+
end
113+
end
114+
115+
private def emit_endpoint(path : String, method : String, path_type : String, hosts : Array(String), origin : String, details : Details)
116+
hosts = [""] if hosts.empty?
117+
hosts.each do |host|
118+
endpoint = Endpoint.new(path, method, details)
119+
endpoint.add_tag(Tag.new("virtualservice-path-type", path_type, "istio_virtualservice_analyzer"))
120+
endpoint.add_tag(Tag.new("virtualservice-host", host, "istio_virtualservice_analyzer")) unless host.empty?
121+
endpoint.add_tag(Tag.new("virtualservice-source", origin, "istio_virtualservice_analyzer"))
122+
@result << endpoint
123+
end
124+
end
125+
end
126+
end

src/detector/detector.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def detect_techs(base_paths : Array(String), options : Hash(String, YAML::Any),
133133
Specification::Oas2,
134134
Specification::Oas3,
135135
Specification::Insomnia,
136+
Specification::IstioVirtualservice,
136137
Specification::Mitmproxy,
137138
Specification::Netlify,
138139
Specification::OData,

0 commit comments

Comments
 (0)