Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scan uploaded attachments #86

Merged
merged 27 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dcc363d
Added virus scan with deployment
Ceredron May 31, 2024
b661dc8
Fix
Ceredron May 31, 2024
d7210f3
Need to add reference to it here too
Ceredron May 31, 2024
088bc68
Merge branch 'main' into feat/virus-scan
Ceredron May 31, 2024
582d1cd
Merge branch 'main' into feat/virus-scan
Ceredron May 31, 2024
0e0961a
defenderForStorageSettings resource name must be current
Ceredron May 31, 2024
0068d7a
Test code
Ceredron May 31, 2024
75e98e7
Remove webhooks path element
Ceredron Jun 3, 2024
14ce0d5
Fix
Ceredron Jun 3, 2024
53b1a9e
Explicitly define as controller
Ceredron Jun 3, 2024
b230b57
Add application handler with logic and re-factored controller code some
Ceredron Jun 4, 2024
1a0782b
Re-factored malware scan controller to better reflect the underlying …
Ceredron Jun 5, 2024
c4342a2
Set data location url in upload attachment
Ceredron Jun 5, 2024
81846ac
Fixed tests
Ceredron Jun 5, 2024
ef29c5e
Prefix with webhook because we have no other signifier that it is a w…
Ceredron Jun 5, 2024
f665f59
Typos
Ceredron Jun 5, 2024
6ba0d94
Scale to zero is annoying when testing.
Ceredron Jun 5, 2024
f2fb831
Merge from main
Ceredron Jun 6, 2024
2237ed9
Revise publish logic
Ceredron Jun 6, 2024
36e3494
Storage connection string should be a connection string, not a key
Ceredron Jun 6, 2024
0e791a8
Delete test. Duplicate did not occur now when testing. Assume it occu…
Ceredron Jun 6, 2024
4a3b07c
Remove comment
Ceredron Jun 6, 2024
d4c6fac
Newlines
Ceredron Jun 6, 2024
6c7aec2
Clean-up
Ceredron Jun 6, 2024
21ad07d
Rename azure storage account parameter name as it is no longer used e…
Ceredron Jun 6, 2024
22c0fc2
Delete this for now, better for another PR
Ceredron Jun 6, 2024
d846bac
Update parameter name too
Ceredron Jun 6, 2024
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
14 changes: 13 additions & 1 deletion .azure/applications/api/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ param platform_base_url string
param sourceKeyVaultName string
@secure()
param keyVaultUrl string

@secure()
param namePrefix string
@secure()
param storageAccountName string

var image = 'ghcr.io/altinn/altinn-correspondence:${imageTag}'
var containerAppName = '${namePrefix}-app'
Expand Down Expand Up @@ -89,5 +90,16 @@ module containerApp '../../modules/containerApp/main.bicep' = {
}
}

module virusScan '../../modules/virusScan/create.bicep' = {
scope: resourceGroup
name: 'virusScan'
params: {
containerAppIngress: containerApp.outputs.containerAppIngress
location: location
namePrefix: namePrefix
storageAccountName: storageAccountName
}
}

output name string = containerApp.outputs.name
output revisionName string = containerApp.outputs.revisionName
1 change: 1 addition & 0 deletions .azure/applications/api/params.bicepparam
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ param environment = readEnvironmentVariable('ENVIRONMENT')
// secrets
param sourceKeyVaultName = readEnvironmentVariable('KEY_VAULT_NAME')
param keyVaultUrl = readEnvironmentVariable('KEY_VAULT_URL')
param storageAccountName = readEnvironmentVariable('STORAGE_ACCOUNT_NAME')
3 changes: 1 addition & 2 deletions .azure/modules/containerApp/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ param namePrefix string
param image string
param environment string
param platform_base_url string

@secure()
param subscription_id string
@secure()
Expand All @@ -13,7 +12,6 @@ param principal_id string
param keyVaultUrl string
@secure()
param userIdentityClientId string

@secure()
param containerAppEnvId string

Expand Down Expand Up @@ -99,3 +97,4 @@ resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
output name string = containerApp.name
output revisionName string = containerApp.properties.latestRevisionName
output app object = containerApp
output containerAppIngress string = containerApp.properties.configuration.ingress.fqdn
48 changes: 48 additions & 0 deletions .azure/modules/virusScan/create.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
param location string
@secure()
param namePrefix string
@secure()
param storageAccountName string
@secure()
param containerAppIngress string

resource eventgrid_topic 'Microsoft.EventGrid/topics@2022-06-15' = {
name: '${namePrefix}-malware-scan-event-topic'
location: location
}

resource eventgrid_event_subscription 'Microsoft.EventGrid/topics/eventSubscriptions@2022-06-15' = {
name: '${namePrefix}-malware-scan-event-subscription'
parent: eventgrid_topic
properties: {
destination: {
endpointType: 'WebHook'
properties: {
endpointUrl: 'https://${containerAppIngress}/correspondence/api/v1/malwarescanresults'
}
}
}
}

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-04-01' existing = {
name: storageAccountName
}

resource malwareScanSettings 'Microsoft.Security/defenderForStorageSettings@2022-12-01-preview' = {
name: 'current'
scope: storageAccount
properties: {
isEnabled: true
malwareScanning: {
onUpload: {
capGBPerMonth: -1
isEnabled: true
}
scanResultsEventGridTopicResourceId: eventgrid_topic.id
}
overrideSubscriptionLevelSettings: true
sensitiveDataDiscovery: {
isEnabled: false
}
}
}
4 changes: 4 additions & 0 deletions .github/actions/release-version/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ inputs:
PLATFORM_BASE_URL:
description: "Base url for Altinn platform"
required: true
STORAGE_ACCOUNT_NAME:
description: "Name of the storage account used for attachments"
required: true

runs:
using: "composite"
Expand All @@ -55,6 +58,7 @@ runs:
CLIENT_ID: ${{ inputs.AZURE_CLIENT_ID }}
TENANT_ID: ${{ inputs.AZURE_TENANT_ID }}
PLATFORM_BASE_URL: ${{ inputs.PLATFORM_BASE_URL }}
STORAGE_ACCOUNT_NAME: ${{ inputs.STORAGE_ACCOUNT_NAME }}
with:
scope: subscription
subscriptionId: ${{ inputs.AZURE_SUBSCRIPTION_ID }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-to-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,5 @@ jobs:
PLATFORM_BASE_URL: ${{ secrets.PLATFORM_BASE_URL }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
STORAGE_ACCOUNT_NAME: ${{ secrets.AZURE_MIGRATION_STORAGE_ACCOUNT_NAME }}

8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ dotnet ef database update
Formatting of the code base is handled by Dotnet format. [See how to configure it to format-on-save in Visual Studio here.](https://learn.microsoft.com/en-us/community/content/how-to-enforce-dotnet-format-using-editorconfig-github-actions#3---formatting-your-code-locally)

## Deploy
The build and push workflow produces a docker image that is pushed to Github packages. This image is then used by the release action. Read more here: [Readme-infrastructure](/README-infrastructure.md)
The build and push workflow produces a docker image that is pushed to Github packages. This image is then used by the release action. Read more here: [Readme-infrastructure](/README-infrastructure.md)

TODO:
Figure out how etag works
Verify that idempotency works as intended
Fix bicep bug with identity id used instead of identity name when deploying/migrating new database
Change secret name from MIRATION_ prefix to without
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Expand All @@ -9,6 +9,24 @@
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<None Remove="Data\MalwareScanResult_Malicious.json" />
<None Remove="Data\MalwareScanResult_NoThreatFound.json" />
<None Remove="Data\WebHookSubscriptionValidationTest.json" />
</ItemGroup>

<ItemGroup>
<Content Include="Data\MalwareScanResult_Malicious.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Data\MalwareScanResult_NoThreatFound.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Data\WebHookSubscriptionValidationTest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"data": {
"blobUri": "https://aitest0192991825827sa.blob.core.windows.net/brokerfiles/--FILEID--",
"correlationId": "2ee9f258-c96a-4982-9e6e-16b8485d71da",
"eTag": "--ETAGID--",
"scanFinishedTimeUtc": "2023-12-08T08:12:31.9933275Z",
"scanResultDetails": {
"malwareNamesFound": [
"Virus:DOS/EICAR_Test_File"
],
"sha256": "275A021BBFB6489E54D471899F7DB9D1663FC695EC2FE2A2C4538AABF651FD0F"
},
"scanResultType": "Malicious"
},
"dataVersion": "1.0",
"eventTime": "2023-12-08T08:12:31.9939079Z",
"eventType": "Microsoft.Security.MalwareScanningResult",
"id": "2ee9f258-c96a-4982-9e6e-16b8485d71da",
"metadataVersion": "1",
"subject": "storageAccounts/aitest0192991825827sa/containers/brokerfiles/blobs/--FILEID--",
"topic": "/subscriptions/81cc3a6b-dfdf-49c7-96f0-3efddb159356/resourceGroups/serviceowner-test-0192-991825827-rg/providers/Microsoft.EventGrid/topics/test-broker-defenderresults"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"data": {
"blobUri": "https://aitest0192991825827sa.blob.core.windows.net/brokerfiles/--FILEID--",
"correlationId": "21c48159-e5ef-4376-ba96-4f8d6e0f1c7f",
"eTag": "--ETAGID--",
"scanFinishedTimeUtc": "2023-12-08T08:11:44.9457492Z",
"scanResultDetails": null,
"scanResultType": "No threats found"
},
"dataVersion": "1.0",
"eventTime": "2023-12-08T08:11:44.9464641Z",
"eventType": "Microsoft.Security.MalwareScanningResult",
"id": "21c48159-e5ef-4376-ba96-4f8d6e0f1c7f",
"metadataVersion": "1",
"subject": "storageAccounts/aitest0192991825827sa/containers/brokerfiles/blobs/--FILEID--",
"topic": "/subscriptions/81cc3a6b-dfdf-49c7-96f0-3efddb159356/resourceGroups/serviceowner-test-0192-991825827-rg/providers/Microsoft.EventGrid/topics/test-broker-defenderresults"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "2d1781af-3a4c-4d7c-bd0c-e34b19da4e66",
"topic": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"subject": "",
"data": {
"validationCode": "512d38b6-c7b8-40c8-89fe-f46f9e9622b6",
"validationUrl": "https://www.contoso.com/"
},
"eventType": "Microsoft.EventGrid.SubscriptionValidationEvent",
"eventTime": "2018-01-25T22:12:19.4556811Z",
"metadataVersion": "1",
"dataVersion": "1"
}
17 changes: 17 additions & 0 deletions Test/Altinn.Correspondence.Tests/Factories/MalwareScanFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Altinn.Correspondence.Application.MalwareScanResultCommand.Models;

namespace Altinn.Correspondence.Tests.Factories
{
internal class MalwareScanFactory
{
internal static ScanResultData GetSuccessfulScanResult() => new ScanResultData()
{
BlobUri = "https://corrtestmigrations.blob.core.windows.net/attachments/arm-ttk.zip",
CorrelationId = "77e1c215-d59d-43e0-a1d9-c479370998a8",
ETag = "0x8DC83A7AD27459B",
ScanResultDetails = null,
ScanResultType = "No threats found",
ScanFinishedTimeUtc = DateTime.Parse("2024-06-03T08:32:23.7483045Z")
};
}
}
120 changes: 120 additions & 0 deletions Test/Altinn.Correspondence.Tests/MalwareScanResultControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text;
using System.Net.Http.Headers;
using Altinn.Correspondence.API.Models.Enums;
using Altinn.Correspondence.API.Models;
using Altinn.Correspondece.Tests.Factories;

public class MalwareScanResultControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _senderClient;
private readonly JsonSerializerOptions _responseSerializerOptions;
private readonly HttpClient _webhookClient;

public MalwareScanResultControllerTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_senderClient = _factory.CreateClientInternal();
_webhookClient = factory.CreateClient();
_responseSerializerOptions = new JsonSerializerOptions(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true
});
_responseSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
}

[Fact]
public async Task MalwareScanFoundNoThreat_Success()
{
// Initialize
var fileId = await UploadAndCheckAttachment();
var jsonBody = GetMalwareScanResultJson("Data/MalwareScanResult_NoThreatFound.json", fileId);
await SendMalvareScanResult(jsonBody);
// Get Scanned status
var scannedFile = await GetattachmentWithNullAndOkCheck(fileId);
Assert.True(scannedFile.Status == AttachmentStatusExt.Published);
Assert.True(scannedFile.StatusText == "Published");
}

[Fact]
public async Task MalwareScanFoundMaliciousSignature_Success()
{
// Initialize
var attachmentId = await UploadAndCheckAttachment();
var jsonBody = GetMalwareScanResultJson("Data/MalwareScanResult_Malicious.json", attachmentId);
await SendMalvareScanResult(jsonBody);

// Get scanned status
var scannedFile = await GetattachmentWithNullAndOkCheck(attachmentId);
Assert.True(scannedFile.Status == AttachmentStatusExt.Failed);
}
[Fact]
public async Task MalwareScanCreatedOneChangeOnDuplicateEvents()
{
// Initialize
var attachmentId = await UploadAndCheckAttachment();
var jsonBody = GetMalwareScanResultJson("Data/MalwareScanResult_NoThreatFound.json", attachmentId);

// Send twice to check if it creates only one change
var scannedattachmentDetailsBeforeScan = await _senderClient.GetFromJsonAsync<AttachmentDetailsExt>($"correspondence/api/v1/attachment/{attachmentId}/details", _responseSerializerOptions);
await SendMalvareScanResult(jsonBody);
await SendMalvareScanResult(jsonBody);
var scannedattachmentDetailsAfterScan = await _senderClient.GetFromJsonAsync<AttachmentDetailsExt>($"correspondence/api/v1/attachment/{attachmentId}/details", _responseSerializerOptions);

Assert.NotNull(scannedattachmentDetailsBeforeScan);
Assert.NotNull(scannedattachmentDetailsAfterScan);
Assert.True(scannedattachmentDetailsBeforeScan.StatusHistory.Count + 1 == scannedattachmentDetailsAfterScan.StatusHistory.Count);
}

[Fact]
public async Task MalwareScanWebhookSubscription_OK()
{
// Webhook
string jsonBody = File.ReadAllText("Data/WebHookSubscriptionValidationTest.json");
var result = await SendMalvareScanResult(jsonBody);
string rs = await result.Content.ReadAsStringAsync();
Assert.Equal("{\"validationResponse\":\"512d38b6-c7b8-40c8-89fe-f46f9e9622b6\"}", rs);
}


private async Task<string> UploadAndCheckAttachment()
{
var initializeattachmentResponse = await _senderClient.PostAsJsonAsync("correspondence/api/v1/attachment", InitializeAttachmentFactory.BasicAttachment());
Assert.True(initializeattachmentResponse.IsSuccessStatusCode, $"The request failed with status code {initializeattachmentResponse.StatusCode}. Error message: {await initializeattachmentResponse.Content.ReadAsStringAsync()}");
var attachmentId = await initializeattachmentResponse.Content.ReadAsStringAsync();

// Upload
var uploadedFileBytes = Encoding.UTF8.GetBytes("This is the contents of the uploaded file");
using (var content = new ByteArrayContent(uploadedFileBytes))
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var uploadResponse = await _senderClient.PostAsync($"correspondence/api/v1/attachment/{attachmentId}/upload", content);
Assert.True(uploadResponse.IsSuccessStatusCode);
}
var attachmentAfterUpload = await GetattachmentWithNullAndOkCheck(attachmentId);
Assert.True(attachmentAfterUpload.Status == AttachmentStatusExt.Published); // When running integration test this happens instantly as of now.
return attachmentId;
}
private string GetMalwareScanResultJson(string filePath, string fileId)
{
string jsonBody = File.ReadAllText(filePath);
var randomizedEtagId = Guid.NewGuid().ToString();
jsonBody = jsonBody.Replace("--FILEID--", fileId);
jsonBody = jsonBody.Replace("--ETAGID--", randomizedEtagId.ToString());
return jsonBody;
}
private async Task<AttachmentOverviewExt> GetattachmentWithNullAndOkCheck(string attachmentId)
{
var attachment = await _senderClient.GetFromJsonAsync<AttachmentOverviewExt>($"correspondence/api/v1/attachment/{attachmentId}", _responseSerializerOptions);
Assert.NotNull(attachment);
return attachment;
}
private async Task<HttpResponseMessage> SendMalvareScanResult(string jsonBody)
{
var result = await _webhookClient.PostAsync("correspondence/api/v1/webhooks/malwarescanresults", new StringContent(jsonBody, Encoding.UTF8, "application/json"));
Assert.True(result.IsSuccessStatusCode, $"The request failed with status code {result.StatusCode}. Error message: {await result.Content.ReadAsStringAsync()}");
return result;
}
}
Loading
Loading