Add 'packages/security/' from commit '711f13afd166eabbd5fe1a9b897e07a85b6fc2c4'

git-subtree-dir: packages/security
git-subtree-mainline: 42a86be549
git-subtree-split: 711f13afd1
This commit is contained in:
Tobe O 2020-02-15 18:22:02 -05:00
commit ac29392d7f
22 changed files with 701 additions and 0 deletions

1
packages/security/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
libinjection/* linguist-vendored

54
packages/security/.gitignore vendored Normal file
View file

@ -0,0 +1,54 @@
# See https://www.dartlang.org/tools/private-files.html
# Files and directories created by pub
.buildlog
.packages
.project
.pub/
build/
**/packages/
# Files created by dart2js
# (Most Dart developers will use pub build to compile Dart, use/modify these
# rules if you intend to use dart2js directly
# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
# differentiate from explicit Javascript files)
*.dart.js
*.part.js
*.js.deps
*.js.map
*.info.json
# Directory created by dartdoc
doc/api/
# Don't commit pubspec lock file
# (Library packages only! Remove pattern if developing an application package)
pubspec.lock
log.txt
.dart_tool
# Created by https://www.gitignore.io/api/cmake
# Edit at https://www.gitignore.io/?templates=cmake
### CMake ###
CMakeLists.txt.user
CMakeCache.txt
CMakeFiles
CMakeScripts
Testing
Makefile
cmake_install.cmake
install_manifest.txt
compile_commands.json
CTestTestfile.cmake
_deps
### CMake Patch ###
# External projects
*-prefix/
# End of https://www.gitignore.io/api/cmake
build/
cmake-build-*

View file

@ -0,0 +1 @@
language: dart

View file

@ -0,0 +1,6 @@
# 2.0.0-alpha.1
* Make `ServiceRateLimiter` more fail-proof.
# 2.0.0-alpha
* Angel 2 updates. Remove previous functionality.
* Add `CookieSigner`, `RateLimiter`/`InMemoryRateLimiter`/`ServiceRateLimiter`.

21
packages/security/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 The Angel Framework
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,8 @@
# security
[![Pub](https://img.shields.io/pub/v/angel_security.svg)](https://pub.dartlang.org/packages/angel_security)
[![build status](https://travis-ci.org/angel-dart/security.svg)](https://travis-ci.org/angel-dart/security)
Angel middleware designed to enhance application security by patching common Web security
holes.
**This package is currently going through a major overhaul, for version 2.**

View file

@ -0,0 +1,4 @@
include: package:pedantic/analysis_options.yaml
analyzer:
strong-mode:
implicit-casts: false

View file

@ -0,0 +1,56 @@
import 'dart:io';
import 'dart:math';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart';
import 'package:angel_security/angel_security.dart';
import 'package:logging/logging.dart';
import 'package:pretty_logging/pretty_logging.dart';
main() async {
// Logging boilerplate.
Logger.root.onRecord.listen(prettyLog);
// Create an app, and HTTP driver.
var app = Angel(logger: Logger('cookie_signer')), http = AngelHttp(app);
// Create a cookie signer. Uses an SHA256 Hmac by default.
var signer = CookieSigner.fromStringKey(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ab');
// When a user visits /getid, give them a (signed) uniqid cookie.
// When they visit /cookies, print their verified cookies.
var rnd = Random.secure();
// Endpoint to give a signed cookie.
app.get('/getid', (req, res) {
// Write the uniqid cookie.
var uniqid = rnd.nextInt(65536);
signer.writeCookie(res, Cookie('uniqid', uniqid.toString()));
// Send a response.
res.write('uniqid=$uniqid');
});
// Endpoint to dump all verified cookies.
//
// The [onInvalidCookie] callback is optional, but
// here we will use it to log invalid cookies.
app.get('/cookies', (req, res) {
var verifiedCookies = signer.readCookies(req, onInvalidCookie: (cookie) {
app.logger.warning('Invalid cookie: $cookie');
});
res.writeln('${verifiedCookies.length} verified cookie(s)');
res.writeln('${req.cookies.length} total unverified cookie(s)');
for (var cookie in verifiedCookies) {
res.writeln('${cookie.name}=${cookie.value}');
}
});
// 404 otherwise.
app.fallback((req, res) => throw AngelHttpException.notFound(
message: 'The only valid endpoints are /getid and /cookies.'));
// Start the server.
await http.startServer('127.0.0.1', 3000);
print('Cookie signer example listening at ${http.uri}');
}

View file

View file

@ -0,0 +1,8 @@
name: example
publish_to: none
dependencies:
angel_production: ^1.0.0
angel_redis: ^1.0.0
angel_security:
path: ../
pretty_logging: ^1.0.0

View file

@ -0,0 +1,34 @@
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart';
import 'package:angel_security/angel_security.dart';
import 'package:logging/logging.dart';
import 'package:pretty_logging/pretty_logging.dart';
main() async {
// Logging boilerplate.
Logger.root.onRecord.listen(prettyLog);
// Create an app, and HTTP driver.
var app = Angel(logger: Logger('rate_limit')), http = AngelHttp(app);
// Create a simple in-memory rate limiter that limits users to 5
// queries per 30 seconds.
//
// In this case, we rate limit users by IP address.
var rateLimiter =
InMemoryRateLimiter(5, Duration(seconds: 30), (req, res) => req.ip);
// `RateLimiter.handleRequest` is a middleware, and can be used anywhere
// a middleware can be used. In this case, we apply the rate limiter to
// *all* incoming requests.
app.fallback(rateLimiter.handleRequest);
// Basic routes.
app
..get('/', (req, res) => 'Hello!')
..fallback((req, res) => throw AngelHttpException.notFound());
// Start the server.
await http.startServer('127.0.0.1', 3000);
print('Rate limiting example listening at ${http.uri}');
}

View file

@ -0,0 +1,39 @@
import 'package:angel_redis/angel_redis.dart';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_production/angel_production.dart';
import 'package:angel_security/angel_security.dart';
import 'package:resp_client/resp_client.dart';
import 'package:resp_client/resp_commands.dart';
// We run this through angel_production, so that we can have
// multiple instances, all using the same Redis queue.
main(List<String> args) =>
Runner('rate_limit_redis', configureServer).run(args);
configureServer(Angel app) async {
// Create a simple rate limiter that limits users to 10
// queries per 30 seconds.
//
// In this case, we rate limit users by IP address.
//
// Our Redis store will be used to manage windows.
var connection = await connectSocket('localhost');
var client = RespClient(connection);
var service =
RedisService(RespCommands(client), prefix: 'rate_limit_redis_example');
var rateLimiter = ServiceRateLimiter(
10, Duration(seconds: 30), service, (req, res) => req.ip);
// `RateLimiter.handleRequest` is a middleware, and can be used anywhere
// a middleware can be used. In this case, we apply the rate limiter to
// *all* incoming requests.
app.fallback(rateLimiter.handleRequest);
// Basic routes.
app
..get('/', (req, res) {
var instance = req.container.make<InstanceInfo>();
res.writeln('This is instance ${instance.id}.');
})
..fallback((req, res) => throw AngelHttpException.notFound());
}

View file

@ -0,0 +1,5 @@
export 'src/cookie_signer.dart';
export 'src/in_memory_rate_limiter.dart';
export 'src/rate_limiter.dart';
export 'src/rate_limiting_window.dart';
export 'src/service_rate_limiter.dart';

View file

@ -0,0 +1,132 @@
import 'dart:convert';
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:crypto/crypto.dart';
/// A utility that signs, and verifies, cookies using an [Hmac].
///
/// It aims to mitigate so-called "cookie poisoning" attacks by
/// ensuring that clients cannot tamper with the cookies they have been
/// sent.
class CookieSigner {
/// The [Hmac] used to sign and verify cookies.
final Hmac hmac;
/// Creates an [hmac] from an array of [keyBytes] and a
/// [hash] (defaults to [sha256]).
CookieSigner(List<int> keyBytes, {Hash hash})
: hmac = Hmac(hash ?? sha256, keyBytes);
CookieSigner.fromHmac(this.hmac);
/// Creates an [hmac] from a string [key] and a
/// [hash] (defaults to [sha256]).
factory CookieSigner.fromStringKey(String key, {Hash hash}) {
return CookieSigner(utf8.encode(key), hash: hash);
}
/// Returns a set of all the incoming cookies that had a
/// valid signature attached. Any cookies without a
/// signature, or with a signature that does not match the
/// provided data, are not included in the output.
///
/// If an [onInvalidCookie] callback is passed, then it will
/// be invoked for each unsigned or improperly-signed cookie.
List<Cookie> readCookies(RequestContext req,
{void Function(Cookie) onInvalidCookie}) {
return req.cookies.fold([], (out, cookie) {
var data = getCookiePayloadAndSignature(cookie.value);
if (data == null || (data[1] != computeCookieSignature(data[0]))) {
if (onInvalidCookie != null) {
onInvalidCookie(cookie);
}
return out;
} else {
return out..add(cookieWithNewValue(cookie, data[0]));
}
});
}
/// Determines whether a cookie is properly signed,
/// if it is signed at all.
///
/// If there is no signature, returns `false`.
/// If the provided signature does not match the payload
/// provided, returns `false`.
/// Otherwise, returns true.
bool verify(Cookie cookie) {
var data = getCookiePayloadAndSignature(cookie.value);
return (data != null && (data[1] == computeCookieSignature(data[0])));
}
/// Gets the payload and signature of a given [cookie], WITHOUT
/// verifying its integrity.
///
/// Returns `null` if no payload can be found.
/// Otherwise, returns a list with a length of 2, where
/// the item at index `0` is the payload, and the item at
/// index `1` is the signature.
List<String> getCookiePayloadAndSignature(String cookieValue) {
var dot = cookieValue.indexOf('.');
if (dot <= 0) {
return null;
} else if (dot >= cookieValue.length - 1) {
return null;
} else {
var payload = cookieValue.substring(0, dot);
var sig = cookieValue.substring(dot + 1);
return [payload, sig];
}
}
/// Signs a single [cookie], and adds it to an outgoing
/// [res]ponse. The input [cookie] is not modified.
///
/// See [createSignedCookie].
void writeCookie(ResponseContext res, Cookie cookie) {
res.cookies.add(createSignedCookie(cookie));
}
/// Signs a set of [cookies], and adds them to an outgoing
/// [res]ponse. The input [cookies] are not modified.
///
/// See [createSignedCookie].
void writeCookies(ResponseContext res, Iterable<Cookie> cookies) {
cookies.forEach((c) => writeCookie(res, c));
}
/// Returns a new cookie, replacing the value of an input
/// [cookie] with one that is signed with the [hmac].
///
/// The new value is:
/// `cookie.value + "." + base64Url(sig)`
///
/// Where `sig` is the cookie's value, signed with the [hmac].
Cookie createSignedCookie(Cookie cookie) {
return cookieWithNewValue(
cookie, cookie.value + '.' + computeCookieSignature(cookie.value));
}
/// Returns a new [Cookie] that is the same as the input
/// [cookie], but with a [newValue].
Cookie cookieWithNewValue(Cookie cookie, String newValue) {
return Cookie(cookie.name, newValue)
..domain = cookie.domain
..expires = cookie.expires
..httpOnly = cookie.httpOnly
..maxAge = cookie.maxAge
..path = cookie.path
..secure = cookie.secure;
}
/// Computes the *signature* of a [cookieValue], either for
/// signing an outgoing cookie, or verifying an incoming cookie.
String computeCookieSignature(String cookieValue) {
// base64Url(cookie) + "." + base64Url(sig)
// var encodedCookie = base64Url.encode(cookieValue.codeUnits);
var sigBytes = hmac.convert(cookieValue.codeUnits).bytes;
return base64Url.encode(sigBytes);
// var sig = base64Url.encode(sigBytes);
// return encodedCookie + '.' + sig;
}
}

View file

@ -0,0 +1,19 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'cookie_signer.dart';
class CsrfToken {
final String value;
CsrfToken(this.value);
}
class CsrfFilter {
final CookieSigner cookieSigner;
CsrfFilter(this.cookieSigner);
Future<CsrfToken> readCsrfToken(RequestContext req) async {
}
}

View file

@ -0,0 +1,5 @@
import 'package:angel_framework/angel_framework.dart';
class Helmet {
}

View file

@ -0,0 +1,30 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'rate_limiter.dart';
import 'rate_limiting_window.dart';
/// A simple [RateLimiter] implementation that uses a simple in-memory map
/// to store rate limiting information.
class InMemoryRateLimiter<User> extends RateLimiter<User> {
/// A callback used to compute the current user.
final FutureOr<User> Function(RequestContext, ResponseContext) getUser;
final _cache = <User, RateLimitingWindow<User>>{};
InMemoryRateLimiter(
int maxPointsPerWindow, Duration windowDuration, this.getUser,
{String errorMessage})
: super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage);
@override
FutureOr<RateLimitingWindow<User>> getCurrentWindow(
RequestContext req, ResponseContext res, DateTime currentTime) async {
var user = await getUser(req, res);
return _cache[user] ??= RateLimitingWindow(user, currentTime, 0);
}
@override
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
RateLimitingWindow<User> window, DateTime currentTime) {
_cache[window.user] = window;
}
}

View file

@ -0,0 +1,156 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_security/angel_security.dart';
import 'rate_limiting_window.dart';
/// A base class that facilitates rate limiting API's or endpoints,
/// typically to prevent spam and abuse.
///
/// The rate limiter operates under the assumption that a [User] object
/// can be computed from each request, as well as information about
/// the current rate-limiting window.
abstract class RateLimiter<User> {
/// The maximum number of points that may be consumed
/// within the given [windowDuration].
final int maxPointsPerWindow;
/// The amount of time, during which, a user is not allowed to consume
/// more than [maxPointsPerWindow].
final Duration windowDuration;
/// The error message to send to a [User] who has exceeded the
/// rate limit during the current window.
///
/// This only applies to the default implementation of
/// [rejectRequest].
final String errorMessage;
RateLimiter(this.maxPointsPerWindow, this.windowDuration,
{String errorMessage})
: this.errorMessage = errorMessage ?? 'Rate limit exceeded.';
/// Computes the current window in which the user is acting.
///
/// For example, if your API was limited to 1000 requests/hour,
/// then you would return a window containing the current hour,
/// and the number of requests the user has sent in the past hour.
FutureOr<RateLimitingWindow<User>> getCurrentWindow(
RequestContext req, ResponseContext res, DateTime currentTime);
/// Updates the underlying store with information about the new
/// [window] that the user is operating in.
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
RateLimitingWindow<User> window, DateTime currentTime);
/// Computes the amount of points that a given request will cost. This amount
/// is then added to the amount of points that the user has already consumed
/// in the current [window].
///
/// The default behavior is to return `1`, which signifies that all requests
/// carry the same weight.
FutureOr<int> getEndpointCost(RequestContext req, ResponseContext res,
RateLimitingWindow<User> window) {
return Future<int>.value(1);
}
/// Alerts the user of information pertinent to the current [window].
///
/// The default implementation is to send the following headers, akin to
/// Github's v4 Graph API:
/// * `X-RateLimit-Limit`: The maximum number of points consumed per window.
/// * `X-RateLimit-Remaining`: The remaining number of points that may be consumed
/// before the rate limit is reached for the current window.
/// * `X-RateLimit-Reset`: The Unix timestamp, at which the window will
/// reset.
FutureOr<void> sendWindowInformation(RequestContext req, ResponseContext res,
RateLimitingWindow<User> window) {
res.headers.addAll({
'x-ratelimit-limit': window.pointLimit.toString(),
'x-ratelimit-remaining': window.remainingPoints.toString(),
'x-ratelimit-reset':
(window.resetTime.millisecondsSinceEpoch ~/ 1000).toString(),
});
}
/// Signals to a user that they have exceeded the rate limit for the
/// current window, and terminates execution of the current [RequestContext].
///
/// The default implementation is throw an [AngelHttpException] with
/// status code `429` and the given `errorMessage`, as well as sending
/// a [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429)
/// header, and then returning `false`.
///
/// Whatever is returned here will be returned in [handleRequest].
FutureOr<Object> rejectRequest(RequestContext req, ResponseContext res,
RateLimitingWindow<User> window, DateTime currentTime) {
var retryAfter = window.resetTime.difference(currentTime);
res.headers['retry-after'] = retryAfter.inSeconds.toString();
throw AngelHttpException(null, message: errorMessage, statusCode: 429);
}
/// A request middleware that returns `true` if the user has not yet
/// exceeded the [maxPointsPerWindow].
///
/// Because this handler is typically called *before* business logic is
/// executed, it technically checks whether the *previous* call raised the
/// number of consumed points to greater than, or equal to, the
/// [maxPointsPerWindow].
Future handleRequest(RequestContext req, ResponseContext res) async {
// Obtain information about the current window.
var now = DateTime.now().toUtc();
var currentWindow = await getCurrentWindow(req, res, now);
// Check if the rate limit has been exceeded. If so, reject the request.
// To perform this check, we must first determine whether a new window
// has begun since the previous request.
var currentWindowEnd = currentWindow.startTime.toUtc().add(windowDuration);
// We must also compute the missing information about the current window,
// so that we can relay that information to the client.
var remainingPoints = maxPointsPerWindow - currentWindow.pointsConsumed;
currentWindow
..pointLimit = maxPointsPerWindow
..remainingPoints = remainingPoints < 0 ? 0 : remainingPoints
..resetTime = currentWindow.startTime.add(windowDuration);
// If the previous window ended in the past, begin a new window.
if (now.compareTo(currentWindowEnd) >= 0) {
// Create a new window.
var cost = await getEndpointCost(req, res, currentWindow);
var remainingPoints = maxPointsPerWindow - cost;
var newWindow = RateLimitingWindow(currentWindow.user, now, cost)
..pointLimit = maxPointsPerWindow
..remainingPoints = remainingPoints < 0 ? 0 : remainingPoints
..resetTime = now.add(windowDuration);
await updateCurrentWindow(req, res, newWindow, now);
await sendWindowInformation(req, res, newWindow);
}
// If we are still within the previous window, check if the user has
// exceeded the rate limit.
//
// Otherwise, update the current window.
//
// At this point in the computation,
// we are still only considering whether the *previous* request took the
// user over the rate limit.
else if (currentWindow.pointsConsumed >= maxPointsPerWindow) {
await sendWindowInformation(req, res, currentWindow);
var result = await rejectRequest(req, res, currentWindow, now);
if (result != null) return result;
return false;
} else {
// Add the cost of the current endpoint, and update the window.
var cost = await getEndpointCost(req, res, currentWindow);
currentWindow
..pointsConsumed += cost
..remainingPoints -= cost;
if (currentWindow.remainingPoints < 0) {
currentWindow.remainingPoints = 0;
}
await updateCurrentWindow(req, res, currentWindow, now);
await sendWindowInformation(req, res, currentWindow);
}
// Pass through, so other handlers can be executed.
return true;
}
}

View file

@ -0,0 +1,50 @@
/// A representation of the abstract "rate-limiting window" in which
/// a [user] is accessing some API or endpoint.
class RateLimitingWindow<User> {
/// The user who is accessing the endpoint.
User user;
/// The time at which the user's current window began.
DateTime startTime;
/// The number of points the user has already consumed within
/// the current window.
int pointsConsumed;
/// The maximum amount of points allowed within a single window.
///
/// This field is typically only set by the [RateLimiter] middleware,
/// and is therefore optional in the constructor.
int pointLimit;
/// The amount of points the user can consume before hitting the
/// rate limit for the current window.
///
/// This field is typically only set by the [RateLimiter] middleware,
/// and is therefore optional in the constructor.
int remainingPoints;
/// The time at which the window will reset.
///
/// This field is typically only set by the [RateLimiter] middleware,
/// and is therefore optional in the constructor.
DateTime resetTime;
RateLimitingWindow(this.user, this.startTime, this.pointsConsumed,
{this.pointLimit, this.remainingPoints, this.resetTime});
factory RateLimitingWindow.fromJson(Map<String, dynamic> map) {
return RateLimitingWindow(
map['user'] as User,
DateTime.parse(map['start_time'] as String),
int.parse(map['points_consumed'] as String));
}
Map<String, dynamic> toJson() {
return {
'user': user,
'start_time': startTime.toIso8601String(),
'points_consumed': pointsConsumed.toString(),
};
}
}

View file

@ -0,0 +1,50 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'rate_limiter.dart';
import 'rate_limiting_window.dart';
/// A RateLimiter] implementation that uses a [Service]
/// to store rate limiting information.
class ServiceRateLimiter<Id> extends RateLimiter<Id> {
/// The underlying [Service] used to store data.
final Service<Id, Map<String, dynamic>> service;
/// A callback used to compute the current user ID.
final FutureOr<Id> Function(RequestContext, ResponseContext) getId;
ServiceRateLimiter(
int maxPointsPerWindow, Duration windowDuration, this.service, this.getId,
{String errorMessage})
: super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage);
@override
FutureOr<RateLimitingWindow<Id>> getCurrentWindow(
RequestContext req, ResponseContext res, DateTime currentTime) async {
var id = await getId(req, res);
try {
var data = await service.read(id);
if (data != null) {
return RateLimitingWindow.fromJson(data);
}
} catch (e) {
if (e is AngelHttpException) {
if (e.statusCode == 404) {
} else {
rethrow;
}
} else {
rethrow;
}
}
var window = RateLimitingWindow(id, currentTime, 0);
await updateCurrentWindow(req, res, window, currentTime);
return window;
}
@override
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
RateLimitingWindow<Id> window, DateTime currentTime) async {
await service.update(window.user, window.toJson());
}
}

View file

@ -0,0 +1,19 @@
name: angel_security
version: 2.0.0-alpha.1
description: Angel infrastructure for improving security, rate limiting, and more.
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/security
environment:
sdk: ">=2.0.0-dev <3.0.0"
dependencies:
angel_framework: ^2.0.0
crypto: ^2.0.0
dev_dependencies:
angel_auth: ^2.0.0
angel_production: ^1.0.0
angel_redis: ^1.0.0
angel_test: ^2.0.0
angel_validate: ^2.0.0
pedantic: ^1.0.0
pretty_logging: ^1.0.0
test: ^1.0.0

View file

@ -0,0 +1,3 @@
import 'package:test/test.dart';
void main() {}