From 31dfb84855fb81f2cc74665bf71549b7f4f16ca5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Nov 2025 17:49:29 -0500 Subject: [PATCH] feat: add security advisories support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement /api/packages//advisories endpoint support following the Hosted Pub Repository Specification Version 2. Changes: - Add packageAdvisories endpoint to Endpoint class - Create PackageAdvisories, SecurityAdvisory, and Affected models following OSV schema 1.6.0 - Add packageAdvisories() method to PubClient that returns null for servers that don't support the endpoint - Include pubDisplayUrl convenience getter for accessing advisory URLs - Add comprehensive tests including integration tests with real API data The implementation gracefully handles: - Packages with advisories (http, dio) - Packages without advisories (flutter) - Servers that don't support the endpoint (404 → null) Closes #67 --- lib/src/endpoints.dart | 3 + lib/src/models/barrel.dart | 1 + lib/src/models/package_advisories_model.dart | 55 ++ .../package_advisories_model.mapper.dart | 487 ++++++++++++++++++ lib/src/pub_api_client_base.dart | 13 + test/advisories_integration_test.dart | 66 +++ test/endpoints_test.dart | 5 + test/pubdev_api_test.dart | 32 ++ 8 files changed, 662 insertions(+) create mode 100644 lib/src/models/package_advisories_model.dart create mode 100644 lib/src/models/package_advisories_model.mapper.dart create mode 100644 test/advisories_integration_test.dart diff --git a/lib/src/endpoints.dart b/lib/src/endpoints.dart index eb427dc..0364255 100644 --- a/lib/src/endpoints.dart +++ b/lib/src/endpoints.dart @@ -36,6 +36,9 @@ class Endpoint { /// Package documentation endpoint String packageDocumentation(String name) => '$apiUrl/documentation/$name'; + /// Security advisories endpoint + String packageAdvisories(String name) => '$packageUrl/$name/advisories'; + // Not part of API endpoint /// Package versions endpoint String packageVersions(String name) => '$baseUrl/packages/$name.json'; diff --git a/lib/src/models/barrel.dart b/lib/src/models/barrel.dart index 9c54117..cf152f9 100644 --- a/lib/src/models/barrel.dart +++ b/lib/src/models/barrel.dart @@ -1,4 +1,5 @@ export 'package:pub_api_client/src/models/latest_version_model.dart'; +export 'package:pub_api_client/src/models/package_advisories_model.dart'; export 'package:pub_api_client/src/models/package_documentation_model.dart'; export 'package:pub_api_client/src/models/package_like_model.dart'; export 'package:pub_api_client/src/models/package_metrics_model.dart'; diff --git a/lib/src/models/package_advisories_model.dart b/lib/src/models/package_advisories_model.dart new file mode 100644 index 0000000..93202ad --- /dev/null +++ b/lib/src/models/package_advisories_model.dart @@ -0,0 +1,55 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +part 'package_advisories_model.mapper.dart'; + +/// Package Advisories Model +@MappableClass() +class PackageAdvisories with PackageAdvisoriesMappable { + final List advisories; + final DateTime? advisoriesUpdated; + + const PackageAdvisories({ + required this.advisories, + this.advisoriesUpdated, + }); + + static const fromMap = PackageAdvisoriesMapper.fromMap; + static const fromJson = PackageAdvisoriesMapper.fromJson; +} + +/// Security Advisory Model (OSV Schema) +@MappableClass() +class SecurityAdvisory with SecurityAdvisoryMappable { + final String id; + final String? summary; + final String? details; + final List? affected; + @MappableField(key: 'database_specific') + final Map? databaseSpecific; + + const SecurityAdvisory({ + required this.id, + this.summary, + this.details, + this.affected, + this.databaseSpecific, + }); + + /// Convenience getter for pub display URL + String? get pubDisplayUrl => + databaseSpecific?['pub_display_url'] as String?; + + static const fromMap = SecurityAdvisoryMapper.fromMap; + static const fromJson = SecurityAdvisoryMapper.fromJson; +} + +/// Affected Model (OSV Schema) +@MappableClass() +class Affected with AffectedMappable { + final List? versions; + + const Affected({this.versions}); + + static const fromMap = AffectedMapper.fromMap; + static const fromJson = AffectedMapper.fromJson; +} diff --git a/lib/src/models/package_advisories_model.mapper.dart b/lib/src/models/package_advisories_model.mapper.dart new file mode 100644 index 0000000..741fad7 --- /dev/null +++ b/lib/src/models/package_advisories_model.mapper.dart @@ -0,0 +1,487 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'package_advisories_model.dart'; + +class PackageAdvisoriesMapper extends ClassMapperBase { + PackageAdvisoriesMapper._(); + + static PackageAdvisoriesMapper? _instance; + static PackageAdvisoriesMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = PackageAdvisoriesMapper._()); + SecurityAdvisoryMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'PackageAdvisories'; + + static List _$advisories(PackageAdvisories v) => + v.advisories; + static const Field> _f$advisories = + Field('advisories', _$advisories); + static DateTime? _$advisoriesUpdated(PackageAdvisories v) => + v.advisoriesUpdated; + static const Field _f$advisoriesUpdated = Field( + 'advisoriesUpdated', + _$advisoriesUpdated, + opt: true, + ); + + @override + final MappableFields fields = const { + #advisories: _f$advisories, + #advisoriesUpdated: _f$advisoriesUpdated, + }; + + static PackageAdvisories _instantiate(DecodingData data) { + return PackageAdvisories( + advisories: data.dec(_f$advisories), + advisoriesUpdated: data.dec(_f$advisoriesUpdated), + ); + } + + @override + final Function instantiate = _instantiate; + + static PackageAdvisories fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static PackageAdvisories fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin PackageAdvisoriesMappable { + String toJson() { + return PackageAdvisoriesMapper.ensureInitialized() + .encodeJson(this as PackageAdvisories); + } + + Map toMap() { + return PackageAdvisoriesMapper.ensureInitialized() + .encodeMap(this as PackageAdvisories); + } + + PackageAdvisoriesCopyWith + get copyWith => + _PackageAdvisoriesCopyWithImpl( + this as PackageAdvisories, + $identity, + $identity, + ); + @override + String toString() { + return PackageAdvisoriesMapper.ensureInitialized().stringifyValue( + this as PackageAdvisories, + ); + } + + @override + bool operator ==(Object other) { + return PackageAdvisoriesMapper.ensureInitialized().equalsValue( + this as PackageAdvisories, + other, + ); + } + + @override + int get hashCode { + return PackageAdvisoriesMapper.ensureInitialized().hashValue( + this as PackageAdvisories, + ); + } +} + +extension PackageAdvisoriesValueCopy<$R, $Out> + on ObjectCopyWith<$R, PackageAdvisories, $Out> { + PackageAdvisoriesCopyWith<$R, PackageAdvisories, $Out> + get $asPackageAdvisories => $base.as( + (v, t, t2) => _PackageAdvisoriesCopyWithImpl<$R, $Out>(v, t, t2), + ); +} + +abstract class PackageAdvisoriesCopyWith<$R, $In extends PackageAdvisories, + $Out> implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, SecurityAdvisory, + SecurityAdvisoryCopyWith<$R, SecurityAdvisory, SecurityAdvisory>> + get advisories; + $R call({List? advisories, DateTime? advisoriesUpdated}); + PackageAdvisoriesCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +class _PackageAdvisoriesCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, PackageAdvisories, $Out> + implements PackageAdvisoriesCopyWith<$R, PackageAdvisories, $Out> { + _PackageAdvisoriesCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + PackageAdvisoriesMapper.ensureInitialized(); + @override + ListCopyWith<$R, SecurityAdvisory, + SecurityAdvisoryCopyWith<$R, SecurityAdvisory, SecurityAdvisory>> + get advisories => ListCopyWith( + $value.advisories, + (v, t) => v.copyWith.$chain(t), + (v) => call(advisories: v), + ); + @override + $R call({ + List? advisories, + Object? advisoriesUpdated = $none, + }) => + $apply( + FieldCopyWithData({ + if (advisories != null) #advisories: advisories, + if (advisoriesUpdated != $none) #advisoriesUpdated: advisoriesUpdated, + }), + ); + @override + PackageAdvisories $make(CopyWithData data) => PackageAdvisories( + advisories: data.get(#advisories, or: $value.advisories), + advisoriesUpdated: data.get( + #advisoriesUpdated, + or: $value.advisoriesUpdated, + ), + ); + + @override + PackageAdvisoriesCopyWith<$R2, PackageAdvisories, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => + _PackageAdvisoriesCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class SecurityAdvisoryMapper extends ClassMapperBase { + SecurityAdvisoryMapper._(); + + static SecurityAdvisoryMapper? _instance; + static SecurityAdvisoryMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = SecurityAdvisoryMapper._()); + AffectedMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'SecurityAdvisory'; + + static String _$id(SecurityAdvisory v) => v.id; + static const Field _f$id = Field('id', _$id); + static String? _$summary(SecurityAdvisory v) => v.summary; + static const Field _f$summary = Field( + 'summary', + _$summary, + opt: true, + ); + static String? _$details(SecurityAdvisory v) => v.details; + static const Field _f$details = Field( + 'details', + _$details, + opt: true, + ); + static List? _$affected(SecurityAdvisory v) => v.affected; + static const Field> _f$affected = Field( + 'affected', + _$affected, + opt: true, + ); + static Map? _$databaseSpecific(SecurityAdvisory v) => + v.databaseSpecific; + static const Field> + _f$databaseSpecific = Field( + 'databaseSpecific', + _$databaseSpecific, + key: r'database_specific', + opt: true, + ); + + @override + final MappableFields fields = const { + #id: _f$id, + #summary: _f$summary, + #details: _f$details, + #affected: _f$affected, + #databaseSpecific: _f$databaseSpecific, + }; + + static SecurityAdvisory _instantiate(DecodingData data) { + return SecurityAdvisory( + id: data.dec(_f$id), + summary: data.dec(_f$summary), + details: data.dec(_f$details), + affected: data.dec(_f$affected), + databaseSpecific: data.dec(_f$databaseSpecific), + ); + } + + @override + final Function instantiate = _instantiate; + + static SecurityAdvisory fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static SecurityAdvisory fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin SecurityAdvisoryMappable { + String toJson() { + return SecurityAdvisoryMapper.ensureInitialized() + .encodeJson(this as SecurityAdvisory); + } + + Map toMap() { + return SecurityAdvisoryMapper.ensureInitialized() + .encodeMap(this as SecurityAdvisory); + } + + SecurityAdvisoryCopyWith + get copyWith => + _SecurityAdvisoryCopyWithImpl( + this as SecurityAdvisory, + $identity, + $identity, + ); + @override + String toString() { + return SecurityAdvisoryMapper.ensureInitialized().stringifyValue( + this as SecurityAdvisory, + ); + } + + @override + bool operator ==(Object other) { + return SecurityAdvisoryMapper.ensureInitialized().equalsValue( + this as SecurityAdvisory, + other, + ); + } + + @override + int get hashCode { + return SecurityAdvisoryMapper.ensureInitialized().hashValue( + this as SecurityAdvisory, + ); + } +} + +extension SecurityAdvisoryValueCopy<$R, $Out> + on ObjectCopyWith<$R, SecurityAdvisory, $Out> { + SecurityAdvisoryCopyWith<$R, SecurityAdvisory, $Out> + get $asSecurityAdvisory => $base + .as((v, t, t2) => _SecurityAdvisoryCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class SecurityAdvisoryCopyWith<$R, $In extends SecurityAdvisory, $Out> + implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, Affected, AffectedCopyWith<$R, Affected, Affected>>? + get affected; + MapCopyWith<$R, String, dynamic, ObjectCopyWith<$R, dynamic, dynamic>>? + get databaseSpecific; + $R call({ + String? id, + String? summary, + String? details, + List? affected, + Map? databaseSpecific, + }); + SecurityAdvisoryCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ); +} + +class _SecurityAdvisoryCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, SecurityAdvisory, $Out> + implements SecurityAdvisoryCopyWith<$R, SecurityAdvisory, $Out> { + _SecurityAdvisoryCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + SecurityAdvisoryMapper.ensureInitialized(); + @override + ListCopyWith<$R, Affected, AffectedCopyWith<$R, Affected, Affected>>? + get affected => $value.affected != null + ? ListCopyWith( + $value.affected!, + (v, t) => v.copyWith.$chain(t), + (v) => call(affected: v), + ) + : null; + @override + MapCopyWith<$R, String, dynamic, ObjectCopyWith<$R, dynamic, dynamic>>? + get databaseSpecific => $value.databaseSpecific != null + ? MapCopyWith( + $value.databaseSpecific!, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(databaseSpecific: v), + ) + : null; + @override + $R call({ + String? id, + Object? summary = $none, + Object? details = $none, + Object? affected = $none, + Object? databaseSpecific = $none, + }) => + $apply( + FieldCopyWithData({ + if (id != null) #id: id, + if (summary != $none) #summary: summary, + if (details != $none) #details: details, + if (affected != $none) #affected: affected, + if (databaseSpecific != $none) #databaseSpecific: databaseSpecific, + }), + ); + @override + SecurityAdvisory $make(CopyWithData data) => SecurityAdvisory( + id: data.get(#id, or: $value.id), + summary: data.get(#summary, or: $value.summary), + details: data.get(#details, or: $value.details), + affected: data.get(#affected, or: $value.affected), + databaseSpecific: + data.get(#databaseSpecific, or: $value.databaseSpecific), + ); + + @override + SecurityAdvisoryCopyWith<$R2, SecurityAdvisory, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => + _SecurityAdvisoryCopyWithImpl<$R2, $Out2>($value, $cast, t); +} + +class AffectedMapper extends ClassMapperBase { + AffectedMapper._(); + + static AffectedMapper? _instance; + static AffectedMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = AffectedMapper._()); + } + return _instance!; + } + + @override + final String id = 'Affected'; + + static List? _$versions(Affected v) => v.versions; + static const Field> _f$versions = Field( + 'versions', + _$versions, + opt: true, + ); + + @override + final MappableFields fields = const {#versions: _f$versions}; + + static Affected _instantiate(DecodingData data) { + return Affected(versions: data.dec(_f$versions)); + } + + @override + final Function instantiate = _instantiate; + + static Affected fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static Affected fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin AffectedMappable { + String toJson() { + return AffectedMapper.ensureInitialized().encodeJson( + this as Affected, + ); + } + + Map toMap() { + return AffectedMapper.ensureInitialized().encodeMap( + this as Affected, + ); + } + + AffectedCopyWith get copyWith => + _AffectedCopyWithImpl( + this as Affected, + $identity, + $identity, + ); + @override + String toString() { + return AffectedMapper.ensureInitialized().stringifyValue(this as Affected); + } + + @override + bool operator ==(Object other) { + return AffectedMapper.ensureInitialized().equalsValue( + this as Affected, + other, + ); + } + + @override + int get hashCode { + return AffectedMapper.ensureInitialized().hashValue(this as Affected); + } +} + +extension AffectedValueCopy<$R, $Out> on ObjectCopyWith<$R, Affected, $Out> { + AffectedCopyWith<$R, Affected, $Out> get $asAffected => + $base.as((v, t, t2) => _AffectedCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class AffectedCopyWith<$R, $In extends Affected, $Out> + implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get versions; + $R call({List? versions}); + AffectedCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _AffectedCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, Affected, $Out> + implements AffectedCopyWith<$R, Affected, $Out> { + _AffectedCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + AffectedMapper.ensureInitialized(); + @override + ListCopyWith<$R, String, ObjectCopyWith<$R, String, String>>? get versions => + $value.versions != null + ? ListCopyWith( + $value.versions!, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(versions: v), + ) + : null; + @override + $R call({Object? versions = $none}) => + $apply(FieldCopyWithData({if (versions != $none) #versions: versions})); + @override + Affected $make(CopyWithData data) => + Affected(versions: data.get(#versions, or: $value.versions)); + + @override + AffectedCopyWith<$R2, Affected, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => + _AffectedCopyWithImpl<$R2, $Out2>($value, $cast, t); +} diff --git a/lib/src/pub_api_client_base.dart b/lib/src/pub_api_client_base.dart index 750bd5c..911b276 100644 --- a/lib/src/pub_api_client_base.dart +++ b/lib/src/pub_api_client_base.dart @@ -8,6 +8,7 @@ import 'constants.dart'; import 'endpoints.dart'; import 'helpers/exceptions.dart'; import 'helpers/recursive_paging.dart'; +import 'models/package_advisories_model.dart'; import 'models/package_documentation_model.dart'; import 'models/package_like_model.dart'; import 'models/package_metrics_model.dart'; @@ -197,6 +198,18 @@ class PubClient { return PackageDocumentation.fromMap(data); } + /// Returns security advisories for [packageName] + /// Returns null if the advisories endpoint is not supported by the server + Future packageAdvisories(String packageName) async { + try { + final data = await _fetch(endpoint.packageAdvisories(packageName)); + return PackageAdvisories.fromMap(data); + } on NotFoundException { + // If advisories endpoint is not supported, return null + return null; + } + } + /// Displays like status of a package Future likePackageStatus(String name) async { final data = await _fetch(endpoint.likePackage(name)); diff --git a/test/advisories_integration_test.dart b/test/advisories_integration_test.dart new file mode 100644 index 0000000..850ffc2 --- /dev/null +++ b/test/advisories_integration_test.dart @@ -0,0 +1,66 @@ +import 'package:pub_api_client/pub_api_client.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +/// Integration test for security advisories using packages known to have advisories +void main() { + final client = PubClient( + debug: true, + client: RateLimitedClient(), + ); + + group('Security Advisories Integration', () { + test('Parse real advisories from http package', () async { + final advisories = await client.packageAdvisories('http'); + + // The http package has known security advisories + if (advisories != null) { + expect(advisories.advisoriesUpdated, isNotNull); + expect(advisories.advisories, isNotEmpty); + + final firstAdvisory = advisories.advisories.first; + expect(firstAdvisory.id, isNotNull); + expect(firstAdvisory.summary, isNotNull); + expect(firstAdvisory.details, isNotNull); + + // Test pubDisplayUrl convenience getter + expect(firstAdvisory.pubDisplayUrl, isNotNull); + expect(firstAdvisory.pubDisplayUrl, contains('github.com/advisories/')); + + print('\nFound ${advisories.advisories.length} advisory(ies) for http package'); + print('Advisory ID: ${firstAdvisory.id}'); + print('Summary: ${firstAdvisory.summary}'); + print('URL: ${firstAdvisory.pubDisplayUrl}'); + } else { + fail('Expected http package to have advisories but got null'); + } + }); + + test('Parse real advisories from dio package', () async { + final advisories = await client.packageAdvisories('dio'); + + if (advisories != null) { + expect(advisories.advisoriesUpdated, isNotNull); + expect(advisories.advisories, isNotEmpty); + + print('\nFound ${advisories.advisories.length} advisory(ies) for dio package'); + for (final advisory in advisories.advisories) { + print('- ${advisory.id}: ${advisory.summary}'); + } + } else { + fail('Expected dio package to have advisories but got null'); + } + }); + + test('Handle package with no advisories (flutter)', () async { + final advisories = await client.packageAdvisories('flutter'); + + if (advisories != null) { + // Should have empty array and null advisoriesUpdated + expect(advisories.advisories, isEmpty); + print('\nflutter package has no advisories (as expected)'); + } + }); + }); +} diff --git a/test/endpoints_test.dart b/test/endpoints_test.dart index 73d0312..fda5393 100644 --- a/test/endpoints_test.dart +++ b/test/endpoints_test.dart @@ -49,6 +49,11 @@ void main() { equals('${Endpoint.defaultBaseUrl}/api/documentation/provider')); }); + test('packageAdvisories returns correct URL', () { + expect(endpoint.packageAdvisories('http'), + equals('${Endpoint.defaultBaseUrl}/api/packages/http/advisories')); + }); + test('packageVersions returns correct URL', () { expect(endpoint.packageVersions('rxdart'), equals('${Endpoint.defaultBaseUrl}/packages/rxdart.json')); diff --git a/test/pubdev_api_test.dart b/test/pubdev_api_test.dart index ee76135..6e5a5f0 100644 --- a/test/pubdev_api_test.dart +++ b/test/pubdev_api_test.dart @@ -257,6 +257,38 @@ void main() { expect(forbidden, throwsA(isA())); expect(internalServerError, throwsA(isA())); }); + + test('Get package security advisories', () async { + // Test with actual package - may return null if endpoint not supported + // Note: This endpoint is not yet implemented on pub.dev as of the test date + // but the client correctly handles the 404 by returning null + final advisories = await _client.packageAdvisories(packageName); + + // The endpoint should either return null (not implemented) or a valid response + if (advisories != null) { + // If the endpoint is implemented, verify the structure + expect(advisories.advisories, isA>()); + // advisoriesUpdated may be null if not provided by the API + + // If there are advisories, verify their structure + if (advisories.advisories.isNotEmpty) { + final advisory = advisories.advisories.first; + expect(advisory.id, isNotNull); + // Summary, details, affected, and databaseSpecific may be null + } + } else { + // Endpoint not implemented - this is expected behavior + expect(advisories, isNull); + } + + // Test with packageName2 + final advisories2 = await _client.packageAdvisories(packageName2); + if (advisories2 != null) { + expect(advisories2.advisories, isA>()); + } else { + expect(advisories2, isNull); + } + }); }); test('Fetch publisher packages', () async {