diff --git a/opa-builtins/build.gradle.kts b/opa-builtins/build.gradle.kts index 5c16ab85..2def8f35 100644 --- a/opa-builtins/build.gradle.kts +++ b/opa-builtins/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(project(":opa-builtins:opa-builtins-net")) api(project(":opa-builtins:opa-builtins-crypto")) api(project(":opa-builtins:opa-builtins-json")) + api(project(":opa-builtins:opa-builtins-providers-aws")) } java { diff --git a/opa-builtins/opa-builtins-providers-aws/build.gradle.kts b/opa-builtins/opa-builtins-providers-aws/build.gradle.kts new file mode 100644 index 00000000..99184b4a --- /dev/null +++ b/opa-builtins/opa-builtins-providers-aws/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` +} + +repositories { + mavenCentral() +} + +dependencies { + api(project(":opa-evaluator")) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} diff --git a/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java b/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java new file mode 100644 index 00000000..25609ed5 --- /dev/null +++ b/opa-builtins/opa-builtins-providers-aws/src/main/java/io/github/open_policy_agent/opa/ast/builtin/impls/ProvidersAwsBuiltins.java @@ -0,0 +1,608 @@ +package io.github.open_policy_agent.opa.ast.builtin.impls; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider; +import io.github.open_policy_agent.opa.ast.builtin.OpaBuiltin; +import io.github.open_policy_agent.opa.ast.builtin.OpaType; +import io.github.open_policy_agent.opa.ast.types.RegoArray; +import io.github.open_policy_agent.opa.ast.types.RegoBigInt; +import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +import io.github.open_policy_agent.opa.ast.types.RegoDecimal; +import io.github.open_policy_agent.opa.ast.types.RegoInt32; +import io.github.open_policy_agent.opa.ast.types.RegoNull; +import io.github.open_policy_agent.opa.ast.types.RegoNumber; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoSet; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import io.github.open_policy_agent.opa.rego.TypeError; + +/** + * Implements the {@code providers.aws.sign_req} builtin, which signs an HTTP request object using + * the AWS Signature Version 4 (header-based, single-chunk) scheme. + * + *

The behavior mirrors the reference OPA implementation in {@code topdown/providers.go} and + * {@code internal/providers/aws/signing_v4.go}. + */ +public class ProvidersAwsBuiltins implements BuiltinProvider { + + private static final DateTimeFormatter DATE_FMT = + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter ISO8601_FMT = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + + private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + private static final String AMZ_CONTENT_SHA256 = "x-amz-content-sha256"; + + // Required AWS config keys (order irrelevant; formatted sorted in error messages). + private static final List REQUIRED_AWS_KEYS = + List.of("aws_service", "aws_access_key", "aws_secret_access_key", "aws_region"); + // Required HTTP request keys. + private static final List REQUIRED_REQUEST_KEYS = List.of("method", "url"); + + // Headers that may be mutated in transit and are therefore excluded from the canonical request. + private static final Set IGNORED_HEADERS = + Set.of("authorization", "user-agent", "x-amzn-trace-id"); + + @Override + public Map> builtins() { + ProvidersAwsBuiltins instance = new ProvidersAwsBuiltins(); + return Map.ofEntries(Map.entry("providers.aws.sign_req", instance::signReq)); + } + + @OpaBuiltin( + name = "providers.aws.sign_req", + description = + "Signs an HTTP request object for Amazon Web Services using AWS Signature Version 4.", + categories = {"providers.aws"}, + args = { + @OpaType(type = "object", name = "request", description = "HTTP request object"), + @OpaType(type = "object", name = "aws_config", description = "AWS configuration object"), + @OpaType(type = "number", name = "time_ns", description = "nanoseconds since the epoch") + }, + result = @OpaType(type = "object", name = "signed_request", description = "signed request")) + public RegoValue signReq(EvaluationContext ctx, RegoValue[] args) { + // Operand 1: request object. + if (!(args[0] instanceof RegoObject)) { + throw new TypeError("operand 1 must be object but got " + args[0].getTypeName()); + } + RegoObject reqObj = (RegoObject) args[0]; + + // Operand 2: AWS config object. + if (!(args[1] instanceof RegoObject)) { + throw new TypeError("operand 2 must be object but got " + args[1].getTypeName()); + } + RegoObject awsConfigObj = (RegoObject) args[1]; + validateAwsAuthParameters(awsConfigObj); + + String service = stringFromProperty(awsConfigObj, "aws_service"); + String accessKey = stringFromProperty(awsConfigObj, "aws_access_key"); + String secretKey = stringFromProperty(awsConfigObj, "aws_secret_access_key"); + String region = stringFromProperty(awsConfigObj, "aws_region"); + String sessionToken = stringFromProperty(awsConfigObj, "aws_session_token"); + + // Operand 3: timestamp in nanoseconds. + Long ts = toUnixNanos(args[2]); + if (ts == null || ts < 0) { + throw new TypeError("operand 3 could not convert time_ns value into a unix timestamp"); + } + Instant signingInstant = + Instant.ofEpochSecond(Math.floorDiv(ts, 1_000_000_000L), Math.floorMod(ts, 1_000_000_000L)); + + // Validate required request keys exist. + validateHttpRequestOperand(reqObj); + + // Required request fields (must be strings). + RegoValue reqUrl = reqObj.getProperty("url"); + RegoValue reqMethod = reqObj.getProperty("method"); + List invalidRequestParams = new ArrayList<>(); + if (!(reqUrl instanceof RegoString)) { + invalidRequestParams.add("url"); + } + if (!(reqMethod instanceof RegoString)) { + invalidRequestParams.add("method"); + } + if (!invalidRequestParams.isEmpty()) { + throw new TypeError( + "operand 1 invalid values for required request parameters(s): " + + formatKeySet(invalidRequestParams)); + } + + String method = ((RegoString) reqMethod).getValue(); + URI url = parseUrl(((RegoString) reqUrl).getValue()); + + // Optional headers object from the request. + RegoObject headersObj = new RegoObject(); + RegoValue headersTerm = reqObj.getProperty("headers"); + if (headersTerm != null) { + if (!(headersTerm instanceof RegoObject)) { + throw new TypeError("operand 0 must be object but got " + headersTerm.getTypeName()); + } + headersObj = (RegoObject) headersTerm; + } + + // disable_payload_signing (optional boolean). + boolean disablePayloadSigning = false; + RegoValue dps = awsConfigObj.getProperty("disable_payload_signing"); + if (dps != null) { + if (dps instanceof RegoBoolean) { + disablePayloadSigning = ((RegoBoolean) dps).getValue(); + } else { + throw new TypeError("operand 2 invalid value for 'disable_payload_signing' in AWS config"); + } + } + + byte[] body = getReqBodyBytes(reqObj); + + // ---- AWS SigV4 signing (mirrors internal/providers/aws.SignV4) ---- + String contentSha256 = disablePayloadSigning ? UNSIGNED_PAYLOAD : sha256Hex(body); + String dateNow = DATE_FMT.format(signingInstant); + String iso8601Now = ISO8601_FMT.format(signingInstant); + + // AWS-managed headers. + Map awsHeaders = new LinkedHashMap<>(); + awsHeaders.put("host", hostFromUrl(url)); + awsHeaders.put("x-amz-date", iso8601Now); + if ("s3".equals(service) || "glacier".equals(service)) { + awsHeaders.put(AMZ_CONTENT_SHA256, contentSha256); + } + if (sessionToken != null && !sessionToken.isEmpty()) { + awsHeaders.put("x-amz-security-token", sessionToken); + } + + // Original request headers (original case) restricted to string key/value pairs. + Map requestHeaders = objectToMap(headersObj); + + // Headers to sign: aws headers first, then request headers (lowercased) which overwrite. + Map headersToSign = new LinkedHashMap<>(awsHeaders); + for (Map.Entry e : requestHeaders.entrySet()) { + String lc = e.getKey().toLowerCase(java.util.Locale.ROOT); + if (!IGNORED_HEADERS.contains(lc)) { + headersToSign.put(lc, e.getValue()); + } + } + + // Canonical request. + TreeMap sortedHeaders = new TreeMap<>(headersToSign); + StringBuilder canonicalReq = new StringBuilder(); + canonicalReq.append(method).append('\n'); + canonicalReq.append(escapedPath(url)).append('\n'); + canonicalReq.append(rawQuery(url)).append('\n'); + for (Map.Entry e : sortedHeaders.entrySet()) { + canonicalReq.append(e.getKey()).append(':').append(e.getValue()).append('\n'); + } + canonicalReq.append('\n'); + String headerList = String.join(";", sortedHeaders.keySet()); + canonicalReq.append(headerList).append('\n'); + canonicalReq.append(contentSha256); + + // String to sign. + String credentialScope = dateNow + "/" + region + "/" + service + "/aws4_request"; + String strToSign = + "AWS4-HMAC-SHA256\n" + + iso8601Now + + "\n" + + credentialScope + + "\n" + + sha256Hex(canonicalReq.toString().getBytes(StandardCharsets.UTF_8)); + + // Signing key via HMAC-SHA256 chaining. + byte[] signingKey = hmacSha256(("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), dateNow); + signingKey = hmacSha256(signingKey, region); + signingKey = hmacSha256(signingKey, service); + signingKey = hmacSha256(signingKey, "aws4_request"); + + String signature = bytesToHex(hmacSha256(signingKey, strToSign)); + + String authHeader = + "AWS4-HMAC-SHA256 Credential=" + + accessKey + + "/" + + credentialScope + + ",SignedHeaders=" + + headerList + + ",Signature=" + + signature; + + // ---- Build the signed request object ---- + RegoObject signedHeaders = new RegoObject(); + // Restore original request headers (original case). + for (Map.Entry e : requestHeaders.entrySet()) { + signedHeaders.setProp(new RegoString(e.getKey()), new RegoString(e.getValue())); + } + signedHeaders.setProp(new RegoString("Authorization"), new RegoString(authHeader)); + // AWS signature headers overwrite any conflicting request headers. + for (Map.Entry e : awsHeaders.entrySet()) { + signedHeaders.setProp(new RegoString(e.getKey()), new RegoString(e.getValue())); + } + + RegoObject out = new RegoObject(); + reqObj.stream().forEach(e -> out.setProp(e.getKey(), e.getValue())); + out.setProp(new RegoString("headers"), signedHeaders); + return out; + } + + // ------------------------------------------------------------------ + // Validation helpers + // ------------------------------------------------------------------ + + private void validateAwsAuthParameters(RegoObject config) { + List missing = new ArrayList<>(); + for (String key : REQUIRED_AWS_KEYS) { + if (config.getProperty(key) == null) { + missing.add(key); + } + } + if (!missing.isEmpty()) { + throw new TypeError( + "operand 2 missing required AWS config parameters(s): " + formatKeySet(missing)); + } + + List invalid = new ArrayList<>(); + for (String key : REQUIRED_AWS_KEYS) { + RegoValue v = config.getProperty(key); + if (!(v instanceof RegoString)) { + invalid.add(key); + } + } + if (!invalid.isEmpty()) { + throw new TypeError( + "operand 2 invalid values for required AWS config parameters(s): " + + formatKeySet(invalid)); + } + } + + private void validateHttpRequestOperand(RegoObject reqObj) { + List missing = new ArrayList<>(); + for (String key : REQUIRED_REQUEST_KEYS) { + if (reqObj.getProperty(key) == null) { + missing.add(key); + } + } + if (!missing.isEmpty()) { + throw new TypeError( + "operand 1 missing required request parameters(s): " + formatKeySet(missing)); + } + } + + /** Formats a collection of keys as an OPA set literal: {@code {"a", "b"}} (sorted, quoted). */ + private static String formatKeySet(Collection keys) { + return keys.stream() + .sorted() + .map(k -> "\"" + k + "\"") + .collect(Collectors.joining(", ", "{", "}")); + } + + // ------------------------------------------------------------------ + // Timestamp handling + // ------------------------------------------------------------------ + + /** + * Converts the timestamp operand into an integer number of nanoseconds, or {@code null} if it + * cannot be represented as a signed 64-bit integer. + */ + private static Long toUnixNanos(RegoValue value) { + if (!(value instanceof RegoNumber)) { + return null; + } + if (value instanceof RegoInt32) { + return (long) ((RegoInt32) value).getValue(); + } + if (value instanceof RegoBigInt) { + BigInteger b = ((RegoBigInt) value).getValue(); + if (b.bitLength() > 63) { + return null; + } + return b.longValue(); + } + if (value instanceof RegoDecimal) { + Double d = ((RegoDecimal) value).getValue(); + if (d == null || d.isInfinite() || d.isNaN() || d != Math.floor(d)) { + return null; + } + if (d < (double) Long.MIN_VALUE || d > (double) Long.MAX_VALUE) { + return null; + } + return d.longValue(); + } + return null; + } + + // ------------------------------------------------------------------ + // Request body / URL helpers + // ------------------------------------------------------------------ + + /** raw_body takes precedence over body; body is JSON-serialized. */ + private byte[] getReqBodyBytes(RegoObject reqObj) { + RegoValue rawBody = reqObj.getProperty("raw_body"); + if (rawBody != null) { + String s = (rawBody instanceof RegoString) ? ((RegoString) rawBody).getValue() : ""; + return s.getBytes(StandardCharsets.UTF_8); + } + RegoValue body = reqObj.getProperty("body"); + if (body != null) { + return canonicalJson(body).getBytes(StandardCharsets.UTF_8); + } + return new byte[0]; + } + + private static URI parseUrl(String value) { + try { + return new URI(value); + } catch (URISyntaxException e) { + throw new TypeError("providers.aws.sign_req: could not parse url: " + value); + } + } + + private static String hostFromUrl(URI url) { + String host = url.getHost(); + if (host == null) { + return ""; + } + int port = url.getPort(); + if (port == -1) { + return host; + } + return host + ":" + port; + } + + private static String escapedPath(URI url) { + String path = url.getRawPath(); + return path == null ? "" : path; + } + + private static String rawQuery(URI url) { + String query = url.getRawQuery(); + return query == null ? "" : query; + } + + /** Extracts string key/value header pairs, preserving original key case. */ + private static Map objectToMap(RegoObject obj) { + Map out = new LinkedHashMap<>(); + obj.stream() + .forEach( + e -> { + if (e.getKey() instanceof RegoString) { + String k = ((RegoString) e.getKey()).getValue(); + String v = + (e.getValue() instanceof RegoString) + ? ((RegoString) e.getValue()).getValue() + : ""; + out.put(k, v); + } + }); + return out; + } + + // ------------------------------------------------------------------ + // Canonical JSON serialization (compact, sorted object keys, sets as arrays) + // ------------------------------------------------------------------ + + private static String canonicalJson(RegoValue value) { + StringBuilder sb = new StringBuilder(); + writeJson(value, sb); + return sb.toString(); + } + + private static void writeJson(RegoValue value, StringBuilder sb) { + if (value == null || value instanceof RegoNull) { + sb.append("null"); + } else if (value instanceof RegoString) { + writeJsonString(((RegoString) value).getValue(), sb); + } else if (value instanceof RegoBoolean) { + sb.append(((RegoBoolean) value).getValue() ? "true" : "false"); + } else if (value instanceof RegoInt32) { + sb.append(((RegoInt32) value).getValue()); + } else if (value instanceof RegoBigInt) { + sb.append(((RegoBigInt) value).getValue().toString()); + } else if (value instanceof RegoDecimal) { + appendGoJsonFloat(((RegoDecimal) value).getValue(), sb); + } else if (value instanceof RegoArray) { + sb.append('['); + List items = ((RegoArray) value).getValues(); + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + sb.append(','); + } + writeJson(items.get(i), sb); + } + sb.append(']'); + } else if (value instanceof RegoSet) { + // A set serializes to a JSON array (elements in the set's sorted order). + sb.append('['); + boolean first = true; + for (RegoValue item : ((RegoSet) value).getValue()) { + if (!first) { + sb.append(','); + } + first = false; + writeJson(item, sb); + } + sb.append(']'); + } else if (value instanceof RegoObject) { + // Object keys are sorted lexicographically to match Go's json.Marshal. + TreeMap sorted = new TreeMap<>(); + ((RegoObject) value) + .stream() + .forEach( + e -> { + String k = + (e.getKey() instanceof RegoString) + ? ((RegoString) e.getKey()).getValue() + : e.getKey().toString(); + sorted.put(k, e.getValue()); + }); + sb.append('{'); + boolean first = true; + for (Map.Entry e : sorted.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + writeJsonString(e.getKey(), sb); + sb.append(':'); + writeJson(e.getValue(), sb); + } + sb.append('}'); + } else { + writeJsonString(value.toString(), sb); + } + } + + /** + * Formats a float64 for canonical JSON, matching Go {@code encoding/json}'s {@code floatEncoder}. + * + *

Go uses {@code strconv.FormatFloat} with precision {@code -1} and format {@code 'f'} when + * {@code 1e-6 <= |f| < 1e21}, otherwise format {@code 'e'}, then strips a single leading zero + * from negative exponents (e.g. {@code e-07} to {@code e-7}). See {@code encode.go} in Go 1.21+. + */ + private static void appendGoJsonFloat(double f, StringBuilder sb) { + if (Double.isNaN(f) || Double.isInfinite(f)) { + sb.append(Double.toString(f)); + return; + } + // Go encodes -0.0 as "0" so the sign is not preserved in JSON output. + if (f == 0.0) { + sb.append('0'); + return; + } + double abs = Math.abs(f); + boolean useExp = abs < 1e-6 || abs >= 1e21; + String formatted = useExp ? formatGoJsonFloatE(f) : formatGoJsonFloatF(f); + if (useExp) { + formatted = cleanupGoJsonExponent(formatted); + } + sb.append(formatted); + } + + private static String formatGoJsonFloatF(double f) { + return BigDecimal.valueOf(f).stripTrailingZeros().toPlainString(); + } + + private static String formatGoJsonFloatE(double f) { + String s = Double.toString(f).replace('E', 'e'); + int eIdx = s.indexOf('e'); + if (eIdx < 0) { + return s; + } + String mantissa = s.substring(0, eIdx); + String exponent = s.substring(eIdx + 1); + if (mantissa.endsWith(".0")) { + mantissa = mantissa.substring(0, mantissa.length() - 2); + } + if (!exponent.startsWith("-")) { + exponent = "+" + exponent; + } + return mantissa + 'e' + exponent; + } + + /** Mirrors Go's post-processing for {@code e-0N} where N is a single digit. */ + private static String cleanupGoJsonExponent(String s) { + int n = s.length(); + if (n >= 4 && s.charAt(n - 4) == 'e' && s.charAt(n - 3) == '-' && s.charAt(n - 2) == '0') { + return s.substring(0, n - 3) + s.charAt(n - 1); + } + return s; + } + + private static void writeJsonString(String s, StringBuilder sb) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '&': + sb.append("\\u0026"); + break; + case '<': + sb.append("\\u003c"); + break; + case '>': + sb.append("\\u003e"); + break; + default: + if (c < 0x20 || c == '\u2028' || c == '\u2029') { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + break; + } + } + sb.append('"'); + } + + // ------------------------------------------------------------------ + // Crypto helpers (byte-oriented HMAC-SHA256 for key derivation chaining) + // ------------------------------------------------------------------ + + private static byte[] hmacSha256(byte[] key, String message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new TypeError("providers.aws.sign_req: HMAC algorithm not available"); + } catch (InvalidKeyException e) { + throw new TypeError("providers.aws.sign_req: invalid HMAC key"); + } + } + + private static String sha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return bytesToHex(digest.digest(data)); + } catch (NoSuchAlgorithmException e) { + throw new TypeError("providers.aws.sign_req: SHA-256 algorithm not available"); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + + private static String stringFromProperty(RegoObject obj, String key) { + RegoValue v = obj.getProperty(key); + return (v instanceof RegoString) ? ((RegoString) v).getValue() : ""; + } +} diff --git a/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider b/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider new file mode 100644 index 00000000..3637d89b --- /dev/null +++ b/opa-builtins/opa-builtins-providers-aws/src/main/resources/META-INF/services/io.github.open_policy_agent.opa.ast.builtin.BuiltinProvider @@ -0,0 +1 @@ +io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java new file mode 100644 index 00000000..303b42c8 --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsCanonicalJsonTest.java @@ -0,0 +1,82 @@ +package io.github.open_policy_agent.opa.ir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins; +import io.github.open_policy_agent.opa.ast.types.RegoDecimal; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import java.lang.reflect.Method; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies canonical JSON serialization matches Go's {@code encoding/json} behavior. + */ +@DisplayName("providers.aws canonical JSON") +class ProvidersAwsCanonicalJsonTest { + + /** + * Expected values verified against Go 1.25 {@code json.Marshal(float64)} via the Go playground. + * + *

Note: {@code json.Number("1e21")} preserves the original literal as {@code 1e21}, but + * marshaling a {@code float64} {@code 1e21} produces {@code 1e+21}. {@code RegoDecimal} follows + * the float64 path. + */ + static Stream goFloatMarshalCases() { + return Stream.of( + Arguments.of(1e21, "1e+21"), + Arguments.of(3.14, "3.14"), + Arguments.of(100.0, "100"), + Arguments.of(1e20, "100000000000000000000"), + Arguments.of(1e-7, "1e-7"), + Arguments.of(1e-6, "0.000001"), + Arguments.of(1.23e10, "12300000000"), + Arguments.of(42.0, "42"), + Arguments.of(-1e21, "-1e+21"), + Arguments.of(-0.0, "0")); + } + + @ParameterizedTest(name = "{0} => {1}") + @MethodSource("goFloatMarshalCases") + void floatFormattingMatchesGoJsonMarshal(double value, String expected) throws Exception { + assertEquals(expected, canonicalJson(new RegoDecimal(value))); + } + + @Test + void floatInObjectMatchesGoJsonMarshal() throws Exception { + RegoObject body = new RegoObject(); + body.setProp(new RegoString("magnitude"), new RegoDecimal(1e21)); + body.setProp(new RegoString("ratio"), new RegoDecimal(3.14)); + body.setProp(new RegoString("count"), new RegoDecimal(100.0)); + assertEquals( + "{\"count\":100,\"magnitude\":1e+21,\"ratio\":3.14}", canonicalJson(body)); + } + + @Test + void htmlEscapesMatchGoEncodingJson() throws Exception { + // Go json.Marshal escapes &, <, >, U+2028, and U+2029 in strings. + String input = "a&bd\u2028e\u2029f"; + String expectedJson = "\"a\\u0026b\\u003cc\\u003ed\\u2028e\\u2029f\""; + + String actualJson = canonicalJson(new RegoString(input)); + assertEquals(expectedJson, actualJson); + + RegoObject body = new RegoObject(); + body.setProp(new RegoString("text"), new RegoString("a&bd")); + String expectedObjectJson = "{\"text\":\"a\\u0026b\\u003cc\\u003ed\"}"; + assertEquals(expectedObjectJson, canonicalJson(body)); + } + + private static String canonicalJson(RegoValue value) throws Exception { + Method method = + ProvidersAwsBuiltins.class.getDeclaredMethod("canonicalJson", RegoValue.class); + method.setAccessible(true); + return (String) method.invoke(null, value); + } +} diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java new file mode 100644 index 00000000..f4e6ba13 --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsHostFromUrlTest.java @@ -0,0 +1,55 @@ +package io.github.open_policy_agent.opa.ir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins; +import io.github.open_policy_agent.opa.ast.types.RegoBigInt; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import java.math.BigInteger; +import java.util.function.BiFunction; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Verifies SigV4 host header reconstruction matches Go's {@code url.Host} (host:port when present). */ +@DisplayName("providers.aws host header") +class ProvidersAwsHostFromUrlTest { + + private static final long SIGNING_TS = 1451311705000000000L; + + @Test + void explicitPortIncludedInHostHeader() { + assertHostHeader("https://example.com:8443/path", "example.com:8443"); + } + + @Test + void portlessUrlUsesHostOnly() { + assertHostHeader("http://example.com", "example.com"); + } + + private static void assertHostHeader(String url, String expectedHost) { + BiFunction fn = + new ProvidersAwsBuiltins().builtins().get("providers.aws.sign_req"); + + RegoObject req = new RegoObject(); + req.setProp(new RegoString("method"), new RegoString("get")); + req.setProp(new RegoString("url"), new RegoString(url)); + + RegoObject config = new RegoObject(); + config.setProp(new RegoString("aws_access_key"), new RegoString("MYAWSACCESSKEYGOESHERE")); + config.setProp( + new RegoString("aws_secret_access_key"), new RegoString("MYAWSSECRETACCESSKEYGOESHERE")); + config.setProp(new RegoString("aws_service"), new RegoString("s3")); + config.setProp(new RegoString("aws_region"), new RegoString("us-east-1")); + + RegoValue[] args = + new RegoValue[] {req, config, new RegoBigInt(BigInteger.valueOf(SIGNING_TS))}; + + RegoObject signed = (RegoObject) fn.apply(null, args); + RegoObject headers = (RegoObject) signed.getProperty("headers"); + String actualHost = ((RegoString) headers.getProperty("host")).getValue(); + assertEquals(expectedHost, actualHost); + } +} diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java new file mode 100644 index 00000000..6d257f3c --- /dev/null +++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/ir/ProvidersAwsSignReqTest.java @@ -0,0 +1,305 @@ +package io.github.open_policy_agent.opa.ir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import io.github.open_policy_agent.opa.OpaException; +import io.github.open_policy_agent.opa.ast.builtin.BuiltinRegistry; +import io.github.open_policy_agent.opa.ast.builtin.impls.ProvidersAwsBuiltins; +import io.github.open_policy_agent.opa.ast.types.RegoBigInt; +import io.github.open_policy_agent.opa.ast.types.RegoBoolean; +import io.github.open_policy_agent.opa.ast.types.RegoInt32; +import io.github.open_policy_agent.opa.ast.types.RegoObject; +import io.github.open_policy_agent.opa.ast.types.RegoSet; +import io.github.open_policy_agent.opa.ast.types.RegoString; +import io.github.open_policy_agent.opa.ast.types.RegoValue; +import io.github.open_policy_agent.opa.ir.policy.Policy; +import io.github.open_policy_agent.opa.jackson.RegoValueModule; +import io.github.open_policy_agent.opa.rego.EvaluationContext; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; +import java.util.function.BiFunction; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Focused tests for the {@code providers.aws.sign_req} builtin. + * + *

Part 1 runs each compiled compliance plan end-to-end (using the fixtures' exact inputs and + * expected values). Part 2 calls the builtin directly for every success case and prints the actual + * computed {@code Authorization} header alongside the expected value extracted from the fixture. + */ +@DisplayName("providers.aws.sign_req") +public class ProvidersAwsSignReqTest { + + private static final String SUCCESS_FIXTURE = + "compliance/Tests/RegoComplianceTests/TestData/v1/providers-aws/aws-sign_req.json"; + private static final String ERROR_FIXTURE = + "compliance/Tests/RegoComplianceTests/TestData/v1/providers-aws/aws-sign_req-errors.json"; + + private static final PolicyReader POLICY_READER = + ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow(); + + private static final Pattern AUTH_PATTERN = + Pattern.compile("\"Authorization\": \"(AWS4-HMAC-SHA256[^\"]*)\""); + + private static final long SIGNING_TS = 1451311705000000000L; + + private static ObjectMapper mapper() { + return new ObjectMapper().registerModule(new RegoValueModule()); + } + + private static JsonNode loadCases(String resource) throws Exception { + try (InputStream in = + ProvidersAwsSignReqTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull(in, "fixture not found on classpath: " + resource); + return mapper().readTree(in).get("cases"); + } + } + + // ------------------------------------------------------------------ + // Part 1: end-to-end evaluation of the compiled plans (all 18 cases) + // ------------------------------------------------------------------ + + static List allComplianceCases() throws Exception { + List out = new ArrayList<>(); + for (JsonNode c : loadCases(SUCCESS_FIXTURE)) { + out.add(Arguments.of(shortNote(c), c, false)); + } + for (JsonNode c : loadCases(ERROR_FIXTURE)) { + out.add(Arguments.of(shortNote(c), c, true)); + } + return out; + } + + @ParameterizedTest(name = "plan[{0}]") + @MethodSource("allComplianceCases") + void endToEndPlan(String note, JsonNode root, boolean expectError) throws Exception { + ObjectMapper mapper = mapper(); + Policy policy = + POLICY_READER.read(new ByteArrayInputStream(mapper.writeValueAsBytes(root.get("plan")))); + String entrypoint = root.get("entrypoints").get(0).asText(); + BuiltinRegistry registry = BuiltinRegistry.allCapabilities(); + EvaluationContext.Builder ctxBuilder = + new EvaluationContext.Builder() + .withBuiltinRegistry(registry) + .withSortedSets() + .withEntrypoint(entrypoint); + if (root.has("strict_error") && root.get("strict_error").asBoolean()) { + ctxBuilder.withStrictBuiltinErrors(); + } + EvaluationContext ctx = ctxBuilder.build(); + Evaluator evaluator = + new Evaluator.Builder().withPolicy(policy).withBuiltinRegistry(registry).build(); + + RegoObject data = mapper.treeToValue(mapper.readTree("{}"), RegoObject.class); + + if (expectError) { + String wantError = root.get("want_error").asText(); + String wantCode = root.get("want_error_code").asText(); + try { + evaluator.evaluate(ctx, parseNull(mapper), data); + fail("[" + note + "] expected error but evaluation succeeded"); + } catch (OpaException e) { + assertEquals(wantCode, e.getErrorCode(), "[" + note + "] error code"); + String actual = e.getMessage(); + assertTrue( + actual.contains(wantError) || wantError.contains(actual), + "[" + note + "] error message mismatch\n expected: " + wantError + "\n actual: " + + actual); + System.out.println("PASS error [" + note + "] " + wantError); + } + } else { + RegoValue[] result = evaluator.evaluate(ctx, parseNull(mapper), data); + String actual = mapper.writeValueAsString(result); + String expected = mapper.writeValueAsString(root.get("want_result")); + assertEquals(expected, actual, "[" + note + "] plan result"); + System.out.println("PASS plan [" + note + "] result=" + actual); + } + } + + private static RegoValue parseNull(ObjectMapper mapper) throws Exception { + return mapper.treeToValue(NullNode.getInstance(), RegoObject.class); + } + + // ------------------------------------------------------------------ + // Part 2: direct builtin invocation, showing computed vs expected signatures + // ------------------------------------------------------------------ + + static List successCases() throws Exception { + List out = new ArrayList<>(); + for (JsonNode c : loadCases(SUCCESS_FIXTURE)) { + String note = shortNote(c); + String module = c.get("modules").get(0).asText(); + Matcher m = AUTH_PATTERN.matcher(module); + assertTrue(m.find(), "could not extract expected Authorization for " + note); + out.add(Arguments.of(note, m.group(1))); + } + return out; + } + + @ParameterizedTest(name = "sign[{0}]") + @MethodSource("successCases") + void computedSignature(String note, String expectedAuthorization) { + BiFunction fn = + new ProvidersAwsBuiltins().builtins().get("providers.aws.sign_req"); + RegoValue[] args = buildArgs(note); + + RegoObject signed = (RegoObject) fn.apply(null, args); + RegoObject headers = (RegoObject) signed.getProperty("headers"); + String actualAuth = ((RegoString) headers.getProperty("Authorization")).getValue(); + RegoValue contentSha = headers.getProperty("x-amz-content-sha256"); + String contentShaStr = contentSha == null ? "(none)" : ((RegoString) contentSha).getValue(); + + String expectedSig = signatureOf(expectedAuthorization); + String actualSig = signatureOf(actualAuth); + + System.out.println("---- " + note + " ----"); + System.out.println(" x-amz-content-sha256: " + contentShaStr); + System.out.println(" expected Signature=" + expectedSig); + System.out.println(" actual Signature=" + actualSig); + System.out.println(" match: " + expectedSig.equals(actualSig)); + + assertEquals(expectedAuthorization, actualAuth, "[" + note + "] Authorization header"); + } + + private static String signatureOf(String authHeader) { + int i = authHeader.indexOf("Signature="); + return i < 0 ? authHeader : authHeader.substring(i + "Signature=".length()); + } + + // ------------------------------------------------------------------ + // Input builders (transcribed from the fixture modules; any mistake surfaces + // as an Authorization mismatch against the fixture-derived expected value). + // ------------------------------------------------------------------ + + private static RegoValue[] buildArgs(String note) { + RegoObject req; + RegoObject config = baseConfig(); + switch (note) { + case "success-simple-no body": + req = req("get", "http://example.com"); + break; + case "success-simple-with headers no body": + req = req("get", "http://example.com"); + req.setProp(str("headers"), obj("foo", str("bar"))); + break; + case "success-simple-no body-with session token": + req = req("get", "http://example.com"); + config.setProp(str("aws_session_token"), str("MYAWSSECURITYTOKENGOESHERE")); + break; + case "success-simple-body": + req = req("get", "http://example.com"); + req.setProp(str("body"), exampleBody()); + break; + case "success-simple-raw_body": + req = req("get", "http://example.com"); + req.setProp(str("raw_body"), str("{\"example\": {1, 2, 3, 4}}")); + break; + case "success-simple-body-and-raw_body": + req = req("get", "http://example.com"); + req.setProp(str("body"), exampleBody()); + req.setProp(str("raw_body"), str("{\"example\": {1, 2, 3, 4}}")); + break; + case "success-simple-with-headers-no-body-with-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), obj("foo", str("bar"))); + config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE); + break; + case "success-simple-with-headers-no-body-no-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), obj("foo", str("bar"))); + config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE); + break; + case "success-simple-with-headers-with-body-with-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), obj("foo", str("bar"))); + req.setProp(str("body"), exampleBody()); + config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE); + break; + case "success-simple-with-headers-with-body-no-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), obj("foo", str("bar"))); + req.setProp(str("body"), exampleBody()); + config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE); + break; + case "success-simple-with-existing-sha-header-with-body-with-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), existingShaHeaders()); + req.setProp(str("body"), exampleBody()); + config.setProp(str("disable_payload_signing"), RegoBoolean.FALSE); + break; + case "success-simple-with-existing-sha-header-with-body-no-payload-signing": + req = req("get", "https://example.com"); + req.setProp(str("headers"), existingShaHeaders()); + req.setProp(str("body"), exampleBody()); + config.setProp(str("disable_payload_signing"), RegoBoolean.TRUE); + break; + default: + throw new IllegalArgumentException("unhandled success case: " + note); + } + return new RegoValue[] {req, config, new RegoBigInt(BigInteger.valueOf(SIGNING_TS))}; + } + + private static RegoString str(String s) { + return new RegoString(s); + } + + private static RegoObject req(String method, String url) { + RegoObject o = new RegoObject(); + o.setProp(str("method"), str(method)); + o.setProp(str("url"), str(url)); + return o; + } + + private static RegoObject obj(String key, RegoValue value) { + RegoObject o = new RegoObject(); + o.setProp(str(key), value); + return o; + } + + private static RegoObject existingShaHeaders() { + RegoObject o = new RegoObject(); + o.setProp(str("foo"), str("bar")); + o.setProp(str("x-amz-content-sha256"), str("existing-value")); + return o; + } + + private static RegoObject baseConfig() { + RegoObject o = new RegoObject(); + o.setProp(str("aws_access_key"), str("MYAWSACCESSKEYGOESHERE")); + o.setProp(str("aws_secret_access_key"), str("MYAWSSECRETACCESSKEYGOESHERE")); + o.setProp(str("aws_service"), str("s3")); + o.setProp(str("aws_region"), str("us-east-1")); + return o; + } + + /** body := {"example": {1, 2, 3, 4}} */ + private static RegoObject exampleBody() { + RegoSet set = new RegoSet(true); + set.addValue(RegoInt32.of(1)); + set.addValue(RegoInt32.of(2)); + set.addValue(RegoInt32.of(3)); + set.addValue(RegoInt32.of(4)); + return obj("example", set); + } + + private static String shortNote(JsonNode c) { + String note = c.get("note").asText(); + int slash = note.indexOf('/'); + return slash < 0 ? note : note.substring(slash + 1); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 16254e0d..310c0593 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,4 +25,5 @@ include("opa-builtins:opa-builtins-semver") include("opa-builtins:opa-builtins-net") include("opa-builtins:opa-builtins-crypto") include("opa-builtins:opa-builtins-json") +include("opa-builtins:opa-builtins-providers-aws") include("opa-slf4j")