Skip to content

Commit 654c6d9

Browse files
author
Anivar A Aravind
committed
feat: Add PURL (Package URL) builtin functions
Add two new built-in functions for working with Package URLs (PURLs): - purl.is_valid(purl): Validates a PURL string - purl.parse(purl): Parses a PURL string into its components These functions help with software supply chain security by making it easier to validate and parse package identifiers in policies. Example usage: purl.is_valid("pkg:npm/[email protected]") # returns true purl.parse("pkg:npm/[email protected]") # returns {type: "npm", name: "express", version: "4.18.2"} Fixes #6504 Signed-off-by: Anivar A Aravind <[email protected]>
1 parent 57543bb commit 654c6d9

File tree

17 files changed

+1150
-0
lines changed

17 files changed

+1150
-0
lines changed

ast/builtins.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ var YAMLUnmarshal = v1.YAMLUnmarshal
324324
// YAMLIsValid verifies the input string is a valid YAML document.
325325
var YAMLIsValid = v1.YAMLIsValid
326326

327+
// PurlIsValid validates that the input is a valid Package URL (PURL).
328+
var PurlIsValid = v1.PurlIsValid
329+
330+
// PurlParse parses a Package URL (PURL) string into its components.
331+
var PurlParse = v1.PurlParse
332+
327333
var HexEncode = v1.HexEncode
328334

329335
var HexDecode = v1.HexDecode

builtin_metadata.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
"json.marshal",
6363
"json.marshal_with_options",
6464
"json.unmarshal",
65+
"purl.is_valid",
66+
"purl.parse",
6567
"urlquery.decode",
6668
"urlquery.decode_object",
6769
"urlquery.encode",
@@ -17075,6 +17077,46 @@
1707517077
},
1707617078
"wasm": false
1707717079
},
17080+
"purl.is_valid": {
17081+
"args": [
17082+
{
17083+
"description": "Package URL string to validate",
17084+
"name": "purl",
17085+
"type": "string"
17086+
}
17087+
],
17088+
"available": [
17089+
"edge"
17090+
],
17091+
"description": "Validates that the input is a valid Package URL (PURL).",
17092+
"introduced": "edge",
17093+
"result": {
17094+
"description": "`true` if `purl` is a valid Package URL; `false` otherwise",
17095+
"name": "result",
17096+
"type": "boolean"
17097+
},
17098+
"wasm": false
17099+
},
17100+
"purl.parse": {
17101+
"args": [
17102+
{
17103+
"description": "Package URL string to parse",
17104+
"name": "purl",
17105+
"type": "string"
17106+
}
17107+
],
17108+
"available": [
17109+
"edge"
17110+
],
17111+
"description": "Parses a Package URL (PURL) string into its components.",
17112+
"introduced": "edge",
17113+
"result": {
17114+
"description": "object containing PURL components: type, namespace, name, version, qualifiers, subpath",
17115+
"name": "result",
17116+
"type": "object[string: any]"
17117+
},
17118+
"wasm": false
17119+
},
1707817120
"rand.intn": {
1707917121
"args": [
1708017122
{

capabilities.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3460,6 +3460,42 @@
34603460
"type": "function"
34613461
}
34623462
},
3463+
{
3464+
"name": "purl.is_valid",
3465+
"decl": {
3466+
"args": [
3467+
{
3468+
"type": "string"
3469+
}
3470+
],
3471+
"result": {
3472+
"type": "boolean"
3473+
},
3474+
"type": "function"
3475+
}
3476+
},
3477+
{
3478+
"name": "purl.parse",
3479+
"decl": {
3480+
"args": [
3481+
{
3482+
"type": "string"
3483+
}
3484+
],
3485+
"result": {
3486+
"dynamic": {
3487+
"key": {
3488+
"type": "string"
3489+
},
3490+
"value": {
3491+
"type": "any"
3492+
}
3493+
},
3494+
"type": "object"
3495+
},
3496+
"type": "function"
3497+
}
3498+
},
34633499
{
34643500
"name": "rand.intn",
34653501
"decl": {

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ require (
8383
github.com/miekg/dns v1.1.57 // indirect
8484
github.com/moby/locker v1.0.1 // indirect
8585
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
86+
github.com/package-url/packageurl-go v0.1.3 // indirect
8687
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
8788
github.com/prometheus/common v0.65.0 // indirect
8889
github.com/prometheus/procfs v0.16.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
121121
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
122122
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
123123
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
124+
github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs=
125+
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
124126
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
125127
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
126128
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=

v1/ast/builtins.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ var DefaultBuiltins = [...]*Builtin{
170170
YAMLMarshal,
171171
YAMLUnmarshal,
172172
YAMLIsValid,
173+
PurlIsValid,
174+
PurlParse,
173175
HexEncode,
174176
HexDecode,
175177

@@ -2063,6 +2065,32 @@ var YAMLIsValid = &Builtin{
20632065
canSkipBctx: true,
20642066
}
20652067

2068+
var PurlIsValid = &Builtin{
2069+
Name: "purl.is_valid",
2070+
Description: "Validates that the input is a valid Package URL (PURL).",
2071+
Decl: types.NewFunction(
2072+
types.Args(
2073+
types.Named("purl", types.S).Description("Package URL string to validate"),
2074+
),
2075+
types.Named("result", types.B).Description("`true` if `purl` is a valid Package URL; `false` otherwise"),
2076+
),
2077+
Categories: encoding,
2078+
canSkipBctx: true,
2079+
}
2080+
2081+
var PurlParse = &Builtin{
2082+
Name: "purl.parse",
2083+
Description: "Parses a Package URL (PURL) string into its components.",
2084+
Decl: types.NewFunction(
2085+
types.Args(
2086+
types.Named("purl", types.S).Description("Package URL string to parse"),
2087+
),
2088+
types.Named("result", types.NewObject(nil, types.NewDynamicProperty(types.S, types.A))).Description("object containing PURL components: type, namespace, name, version, qualifiers, subpath"),
2089+
),
2090+
Categories: encoding,
2091+
canSkipBctx: true,
2092+
}
2093+
20662094
var HexEncode = &Builtin{
20672095
Name: "hex.encode",
20682096
Description: "Serializes the input string using hex-encoding.",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
cases:
3+
- note: purlbuiltins/is_valid_npm
4+
query: data.generated.p = x
5+
modules:
6+
- |
7+
package generated
8+
p := x if {
9+
purl.is_valid("pkg:npm/[email protected]", x)
10+
}
11+
want_result:
12+
- x: true
13+
14+
- note: purlbuiltins/is_valid_maven
15+
query: data.generated.p = x
16+
modules:
17+
- |
18+
package generated
19+
p := x if {
20+
purl.is_valid("pkg:maven/org.apache.xmlgraphics/[email protected]", x)
21+
}
22+
want_result:
23+
- x: true
24+
25+
- note: purlbuiltins/is_valid_invalid
26+
query: data.generated.p = x
27+
modules:
28+
- |
29+
package generated
30+
p := x if {
31+
purl.is_valid("not-a-purl", x)
32+
}
33+
want_result:
34+
- x: false
35+
36+
- note: purlbuiltins/is_valid_empty
37+
query: data.generated.p = x
38+
modules:
39+
- |
40+
package generated
41+
p := x if {
42+
purl.is_valid("", x)
43+
}
44+
want_result:
45+
- x: false
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
cases:
3+
- note: purlbuiltins/parse_simple
4+
query: data.generated.p = x
5+
modules:
6+
- |
7+
package generated
8+
p := x if {
9+
purl.parse("pkg:npm/[email protected]", x)
10+
}
11+
want_result:
12+
- x:
13+
type: "npm"
14+
name: "foobar"
15+
version: "12.3.1"
16+
17+
- note: purlbuiltins/parse_with_namespace
18+
query: data.generated.p = x
19+
modules:
20+
- |
21+
package generated
22+
p := x if {
23+
purl.parse("pkg:maven/org.apache.xmlgraphics/[email protected]", x)
24+
}
25+
want_result:
26+
- x:
27+
type: "maven"
28+
namespace: "org.apache.xmlgraphics"
29+
name: "batik-anim"
30+
version: "1.9.1"
31+
32+
- note: purlbuiltins/parse_with_qualifiers
33+
query: data.generated.p = x
34+
modules:
35+
- |
36+
package generated
37+
p := x if {
38+
purl.parse("pkg:rpm/fedora/[email protected]?arch=i386&distro=fedora-25", x)
39+
}
40+
want_result:
41+
- x:
42+
type: "rpm"
43+
namespace: "fedora"
44+
name: "curl"
45+
version: "7.50.3-1.fc25"
46+
qualifiers:
47+
arch: "i386"
48+
distro: "fedora-25"
49+
50+
- note: purlbuiltins/parse_with_subpath
51+
query: data.generated.p = x
52+
modules:
53+
- |
54+
package generated
55+
p := x if {
56+
purl.parse("pkg:github/owner/[email protected]#path/to/file.js", x)
57+
}
58+
want_result:
59+
- x:
60+
type: "github"
61+
namespace: "owner"
62+
name: "repo"
63+
version: "v1.0.0"
64+
subpath: "path/to/file.js"
65+
66+
- note: purlbuiltins/parse_minimal
67+
query: data.generated.p = x
68+
modules:
69+
- |
70+
package generated
71+
p := x if {
72+
purl.parse("pkg:npm/lodash", x)
73+
}
74+
want_result:
75+
- x:
76+
type: "npm"
77+
name: "lodash"
78+
79+
- note: purlbuiltins/parse_invalid
80+
query: data.generated.p = x
81+
modules:
82+
- |
83+
package generated
84+
p := x if {
85+
purl.parse("not-a-purl", x)
86+
}
87+
want_error: 'purl.parse: invalid PURL'
88+
strict_error: true

v1/topdown/purl.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2025 The OPA Authors. All rights reserved.
2+
// Use of this source code is governed by an Apache2
3+
// license that can be found in the LICENSE file.
4+
5+
package topdown
6+
7+
import (
8+
"fmt"
9+
10+
"github.com/package-url/packageurl-go"
11+
12+
"github.com/open-policy-agent/opa/v1/ast"
13+
"github.com/open-policy-agent/opa/v1/topdown/builtins"
14+
)
15+
16+
func builtinPurlIsValid(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
17+
str, err := builtins.StringOperand(operands[0].Value, 1)
18+
if err != nil {
19+
return iter(ast.InternedTerm(false))
20+
}
21+
22+
_, err = packageurl.FromString(string(str))
23+
return iter(ast.InternedTerm(err == nil))
24+
}
25+
26+
func builtinPurlParse(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error {
27+
str, err := builtins.StringOperand(operands[0].Value, 1)
28+
if err != nil {
29+
return err
30+
}
31+
32+
purl, err := packageurl.FromString(string(str))
33+
if err != nil {
34+
return fmt.Errorf("invalid PURL %q: %w", str, err)
35+
}
36+
37+
// Create object with required fields
38+
obj := ast.NewObject(
39+
[2]*ast.Term{ast.InternedTerm("type"), ast.StringTerm(purl.Type)},
40+
[2]*ast.Term{ast.InternedTerm("name"), ast.StringTerm(purl.Name)},
41+
)
42+
43+
// Add optional fields only if present
44+
if purl.Namespace != "" {
45+
obj.Insert(ast.InternedTerm("namespace"), ast.StringTerm(purl.Namespace))
46+
}
47+
if purl.Version != "" {
48+
obj.Insert(ast.InternedTerm("version"), ast.StringTerm(purl.Version))
49+
}
50+
if purl.Subpath != "" {
51+
obj.Insert(ast.InternedTerm("subpath"), ast.StringTerm(purl.Subpath))
52+
}
53+
54+
// Add qualifiers only if present
55+
if len(purl.Qualifiers) > 0 {
56+
qualifiers := ast.NewObject()
57+
for _, q := range purl.Qualifiers {
58+
qualifiers.Insert(ast.StringTerm(q.Key), ast.StringTerm(q.Value))
59+
}
60+
obj.Insert(ast.InternedTerm("qualifiers"), ast.NewTerm(qualifiers))
61+
}
62+
63+
return iter(ast.NewTerm(obj))
64+
}
65+
66+
func init() {
67+
RegisterBuiltinFunc(ast.PurlIsValid.Name, builtinPurlIsValid)
68+
RegisterBuiltinFunc(ast.PurlParse.Name, builtinPurlParse)
69+
}

vendor/github.com/package-url/packageurl-go/.gitignore

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)