Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"sharpfuzz.commandline": {
"version": "2.3.0",
"commands": [
"sharpfuzz"
]
}
}
}
98 changes: 98 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: fuzz

# Coverage-guided fuzzing of the parsing surface. Not a pull request check: a useful run takes
# minutes, and the corpus it builds up is what makes later runs find anything. See
# csharp/PhoneNumbers.Fuzz/README.md for running the same thing locally.

on:
schedule:
# Sundays, offset from the other scheduled jobs in this repo.
- cron: '15 4 * * 0'
workflow_dispatch:
inputs:
duration:
description: 'Seconds to fuzz for'
required: false
default: '900'

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

jobs:
fuzz:
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
env:
DURATION: ${{ github.event.inputs.duration || '900' }}
PUBLISH_DIR: fuzz-out
CORPUS_DIR: csharp/PhoneNumbers.Fuzz/corpus
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x

# The bridge that lets libFuzzer drive a managed target. Built from source rather than taken
# from the release page: the releases are x64 and these runners are arm64.
- name: Build libfuzzer-dotnet
run: |
if ! command -v clang > /dev/null
then
sudo apt-get update
sudo apt-get install -y clang
fi
git clone --no-checkout https://github.com/Metalnem/libfuzzer-dotnet libfuzzer-dotnet-src
git -C libfuzzer-dotnet-src checkout bd39d4e88d715ab460a929943645be2a186cde52
clang -fsanitize=fuzzer libfuzzer-dotnet-src/libfuzzer-dotnet.cc -o libfuzzer-dotnet

# Findings come from the corpus growing across runs, so carry it forward. The key is unique
# per run because a cache entry cannot be overwritten; restore-keys picks the newest prefix
# match on the way in.
- name: Restore corpus
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: corpus-cache
key: fuzz-corpus-${{ github.run_id }}
restore-keys: |
fuzz-corpus-

- name: Publish the fuzz target
run: dotnet publish csharp/PhoneNumbers.Fuzz -c Release -o "${PUBLISH_DIR}"

# Only the library under test is instrumented: coverage of the harness, SharpFuzz or dnlib
# would just be noise steering the fuzzer away from PhoneNumbers.
- name: Instrument PhoneNumbers.dll
run: |
dotnet tool restore
dotnet sharpfuzz "${PUBLISH_DIR}/PhoneNumbers.dll"

- name: Fuzz
run: |
mkdir -p corpus-cache artifacts
cp -n "${CORPUS_DIR}"/* corpus-cache/ || true
./libfuzzer-dotnet \
-timeout=10 \
-max_total_time="${DURATION}" \
-print_final_stats=1 \
-artifact_prefix=artifacts/ \
--target_path=dotnet \
--target_arg="${PUBLISH_DIR}/PhoneNumbers.Fuzz.dll" \
corpus-cache

# A crash fails the step above, so this runs on failure to carry the input out. Reproduce it
# with: dotnet fuzz-out/PhoneNumbers.Fuzz.dll <artifact>
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-crashes
path: artifacts/
if-no-files-found: ignore
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,11 @@ csharp/PhoneNumbers.PerformanceTest/BenchmarkDotNet.Artifacts/

# Coverage reports
coverage/

# Fuzzing scratch: the bridge, the published target, the working corpus and any crashes.
# See csharp/PhoneNumbers.Fuzz/README.md.
/libfuzzer-dotnet
/libfuzzer-dotnet-src/
/fuzz-out/
/corpus-cache/
/artifacts/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The library tracks upstream metadata releases (~every two weeks) via the `create
- `csharp/coverlet.runsettings` — keeps the generated data tables out of coverage instrumentation; passed by the coverage workflow.
- `resources/` — XML metadata (`PhoneNumberMetadata.xml`, `ShortNumberMetadata.xml`, `PhoneNumberAlternateFormats.xml`, `PhoneNumberMetadataForTesting.xml`), plus `geocoding/`, `carrier/`, `timezones/`. **These are copied verbatim from upstream** (`locale/` is the exception: it is generated from the local jdk by `DumpLocale.java`) — do not hand-edit. The library no longer reads them at runtime: the build pipeline emits binary equivalents under `obj/metadata/`, `obj/geocoding/`, `obj/timezones/` which are embedded into the published assembly.
- `lib/github-actions-metadata-update.sh` + `lib/DumpLocale.java` — automation that pulls upstream resources and regenerates `resources/locale/country_names.txt`.
- `csharp/PhoneNumbers.Fuzz/` — SharpFuzz/libFuzzer target for the parsing surface, run weekly by `fuzz.yml`. Not in the solution; see its README and the note in its csproj.

## Common commands

Expand Down
3 changes: 3 additions & 0 deletions csharp/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="bunit" Version="2.9.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FsCheck" Version="3.3.4" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.4" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="System.Collections.Immutable" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
<PackageVersion Include="xunit" Version="2.9.3" />
Expand Down
25 changes: 25 additions & 0 deletions csharp/PhoneNumbers.Fuzz/PhoneNumbers.Fuzz.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>PhoneNumbers.Fuzz</AssemblyName>
<RootNamespace>PhoneNumbers.Fuzz</RootNamespace>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!--
Deliberately not in PhoneNumbers.slnx: SharpFuzz drags in dnlib to rewrite IL, which nothing
else here needs, and no other build has a reason to restore it. Built directly by
.github/workflows/fuzz.yml, which is the only thing that needs it.
-->
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\PhoneNumbers\PhoneNumbers.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="SharpFuzz" />
</ItemGroup>

</Project>
102 changes: 102 additions & 0 deletions csharp/PhoneNumbers.Fuzz/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using System.Linq;
using System.Text;
using SharpFuzz;

namespace PhoneNumbers.Fuzz
{
/// <summary>
/// Coverage-guided fuzz target for the untrusted-input surface named in SECURITY.md: the strings
/// callers pass to the public API. libFuzzer drives this through the libfuzzer-dotnet bridge -
/// see README.md in this directory for how to run it.
///
/// Anything thrown out of <see cref="Fuzz"/> is reported as a crash, so the expected failure
/// (NumberParseException for input that is not a phone number) is caught here and everything
/// else is left to escape.
/// </summary>
internal static class Program
{
private static readonly PhoneNumberUtil PhoneUtil = PhoneNumberUtil.GetInstance();

/// <summary>Ordered so a given input byte always selects the same region.</summary>
private static readonly string[] Regions =
PhoneUtil.GetSupportedRegions().OrderBy(r => r, StringComparer.Ordinal).ToArray();

private static readonly PhoneNumberOfflineGeocoder Geocoder = PhoneNumberOfflineGeocoder.GetInstance();
private static readonly PhoneNumberToCarrierMapper CarrierMapper = PhoneNumberToCarrierMapper.GetInstance();
private static readonly PhoneNumberToTimeZonesMapper TimeZonesMapper = PhoneNumberToTimeZonesMapper.GetInstance();
private static readonly ShortNumberInfo ShortInfo = ShortNumberInfo.GetInstance();

public static void Main() => Fuzzer.LibFuzzer.Run(Fuzz);

private static void Fuzz(ReadOnlySpan<byte> span)
{
if (span.IsEmpty)
return;

// First byte picks the default region, the rest is the number. Giving the fuzzer a byte
// to steer with is what lets it reach region-specific parsing and formatting branches.
var region = Regions[span[0] % Regions.Length];
var input = Encoding.UTF8.GetString(span.Slice(1));

PhoneNumberUtil.Normalize(input);
PhoneNumberUtil.NormalizeDigitsOnly(input);
PhoneNumberUtil.NormalizeDiallableCharsOnly(input);
PhoneNumberUtil.ConvertAlphaCharactersInNumber(input);
PhoneNumberUtil.IsViablePhoneNumber(input);
PhoneNumberUtil.ExtractPossibleNumber(input);

FindNumbers(input, region);
FormatAsYouType(input, region);

PhoneNumber number;
try
{
number = PhoneUtil.Parse(input, region);
}
catch (NumberParseException)
{
return;
}

ExerciseReadOnlySurface(number);
}

private static void FindNumbers(string input, string region)
{
foreach (var _ in PhoneUtil.FindNumbers(input, region))
{
// Enumerating is the point: the matcher does its work lazily.
}
}

private static void FormatAsYouType(string input, string region)
{
var formatter = PhoneUtil.GetAsYouTypeFormatter(region);

// Bounded because the formatter is per-keystroke: without a cap a long input turns a
// single fuzz iteration into thousands of calls and starves the rest of the target.
for (var i = 0; i < input.Length && i < 200; i++)
formatter.InputDigit(input[i]);
}

private static void ExerciseReadOnlySurface(PhoneNumber number)
{
PhoneUtil.IsValidNumber(number);
PhoneUtil.IsPossibleNumber(number);
PhoneUtil.GetNumberType(number);
PhoneUtil.GetRegionCodeForNumber(number);

PhoneUtil.Format(number, PhoneNumberFormat.E164);
PhoneUtil.Format(number, PhoneNumberFormat.INTERNATIONAL);
PhoneUtil.Format(number, PhoneNumberFormat.NATIONAL);
PhoneUtil.Format(number, PhoneNumberFormat.RFC3966);
PhoneUtil.FormatOutOfCountryCallingNumber(number, "US");

Geocoder.GetDescriptionForNumber(number, Locale.English);
CarrierMapper.GetNameForNumber(number, Locale.English);
TimeZonesMapper.GetTimeZonesForNumber(number);
ShortInfo.IsPossibleShortNumber(number);
}
}
}
58 changes: 58 additions & 0 deletions csharp/PhoneNumbers.Fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Fuzzing

Coverage-guided fuzzing of the public parsing surface, using [SharpFuzz] to instrument
`PhoneNumbers.dll` and [libFuzzer] to drive it through the [libfuzzer-dotnet] bridge.

`Program.cs` reads one input as `<region byte><number text>`: the first byte selects the default
region, the rest is the string handed to `Parse` and friends. `NumberParseException` is the
documented failure for input that is not a phone number, so it is caught; anything else that
escapes is a crash.

This project is not in `PhoneNumbers.slnx` — see the comment in the `.csproj`.

## Running it locally

Needs clang (to build the bridge) and the .NET SDK. Linux only: the libfuzzer-dotnet bridge does
not support macOS.

```bash
# 1. Build the libFuzzer bridge.
git clone https://github.com/Metalnem/libfuzzer-dotnet libfuzzer-dotnet-src
clang -fsanitize=fuzzer libfuzzer-dotnet-src/libfuzzer-dotnet.cc -o libfuzzer-dotnet

# 2. Publish the target and instrument the library under test.
dotnet publish csharp/PhoneNumbers.Fuzz -c Release -o fuzz-out
dotnet tool restore
dotnet sharpfuzz fuzz-out/PhoneNumbers.dll

# 3. Fuzz. Crashes are written to artifacts/.
mkdir -p artifacts
./libfuzzer-dotnet -timeout=10 -artifact_prefix=artifacts/ \
--target_path=dotnet --target_arg=fuzz-out/PhoneNumbers.Fuzz.dll \
csharp/PhoneNumbers.Fuzz/corpus
```

Only `PhoneNumbers.dll` is instrumented. Instrumenting `PhoneNumbers.Fuzz.dll` itself, SharpFuzz,
or dnlib would report coverage for the harness rather than the library.

Add `-max_total_time=<seconds>` to bound a run, or `-runs=<n>` to bound it by iteration count.

## Reproducing a crash

A crash file is just an input. The target runs it directly when it is not hosted by libFuzzer:

```bash
dotnet fuzz-out/PhoneNumbers.Fuzz.dll artifacts/crash-<hash>
```

That reproduces under a debugger too, which is usually the fastest way to get a stack trace. Turn
anything it finds into a case in `PhoneNumbers.Test/TestPublicApiRobustness.cs` so it stays fixed.

## In CI

`.github/workflows/fuzz.yml` runs this weekly and on demand, seeded from the corpus here and from
the previous run's cached corpus. A crash fails the job and uploads the input as an artifact.

[SharpFuzz]: https://github.com/Metalnem/sharpfuzz
[libFuzzer]: https://llvm.org/docs/LibFuzzer.html
[libfuzzer-dotnet]: https://github.com/Metalnem/libfuzzer-dotnet
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/alpha
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
H1-800-FLOWERS
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/e164
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A+14156667777
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/extension
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
D+1 (650) 253-0000 ext. 1234
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/free-text
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GCall me at 650-253-0000 or +44 117 496 0123.
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/intl-prefix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
F011 44 20 7496 0123
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/italian-leading-zero
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
I+390612345678
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/national
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
B6502530000
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/rfc3966
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ctel:+1-201-555-0123;ext=1234
1 change: 1 addition & 0 deletions csharp/PhoneNumbers.Fuzz/corpus/short-code
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
E83835
4 changes: 4 additions & 0 deletions csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<!-- Property-based tests: see TestPhoneNumberProperties. FsCheck is referenced directly as
well as through FsCheck.Xunit because the generators are used explicitly. -->
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
Expand Down
Loading