├── .github ├── CODEOWNERS └── workflows │ ├── assign-issue-to-project.yml │ ├── container-scan.yml │ ├── use-case-PROD.yaml │ ├── use-case-TT02.yaml │ ├── codeql-analysis.yml │ ├── build-and-analyze.yml │ └── use-case-ATX.yaml ├── test ├── k6 │ ├── src │ │ ├── data │ │ │ ├── kattebilde.png │ │ │ └── instance.json │ │ ├── errorhandler.js │ │ ├── config.js │ │ ├── api │ │ │ ├── storage.js │ │ │ └── token-generator.js │ │ ├── report.js │ │ ├── setup.js │ │ └── tests │ │ │ └── end2end.js │ ├── docker-compose.yml │ └── readme.md ├── Altinn.FileScan.Tests │ ├── platform-org.pfx │ ├── jwtselfsignedcert.pfx │ ├── GlobalSuppressions.cs │ ├── appsettings.json │ ├── Mocks │ │ ├── PublicSigningKeyProviderMock.cs │ │ ├── Authentication │ │ │ ├── JwtCookiePostConfigureOptionsStub.cs │ │ │ └── ConfigurationManagerStub.cs │ │ └── JwtTokenMock.cs │ ├── platform-org.pem │ ├── JWTValidationCert.cer │ ├── Altinn.FileScan.Tests.csproj │ ├── Utils │ │ └── PrincipalUtil.cs │ ├── TestingControllers │ │ └── DataElementControllerTests.cs │ └── TestingServices │ │ └── DataElementServiceTests.cs └── Altinn.FileScan.Functions.Tests │ ├── platform-org.pfx │ ├── GlobalSuppressions.cs │ ├── Altinn.FileScan.Functions.Tests.csproj │ └── TestingServices │ └── CertificateResolverServiceTests.cs ├── .dockerignore ├── src ├── Altinn.FileScan.Functions │ ├── Properties │ │ ├── serviceDependencies.json │ │ └── serviceDependencies.local.json │ ├── Configuration │ │ ├── CertificateResolverSettings.cs │ │ ├── PlatformSettings.cs │ │ └── KeyVaultSettings.cs │ ├── Clients │ │ ├── Interfaces │ │ │ └── IFileScanClient.cs │ │ └── FileScanClient.cs │ ├── host.json │ ├── Services │ │ ├── Interfaces │ │ │ ├── ICertificateResolverService.cs │ │ │ └── IKeyVaultService.cs │ │ ├── KeyVaultService.cs │ │ └── CertificateResolverService.cs │ ├── local.settings.json │ ├── TelemetryInitializer.cs │ ├── Extentions │ │ └── HttpClientExtension.cs │ ├── FileScanInbound.cs │ ├── Program.cs │ └── Altinn.FileScan.Functions.csproj └── Altinn.FileScan │ ├── appsettings.Production.json │ ├── Services │ ├── Interfaces │ │ ├── IAccessToken.cs │ │ ├── IDataElement.cs │ │ ├── IPlatformKeyVault.cs │ │ └── IAppOwnerKeyVault.cs │ ├── AppOwnerKeyVaultService.cs │ ├── PlatformKeyVaultService.cs │ ├── AccessTokenService.cs │ └── DataElementService.cs │ ├── Models │ ├── BlobPropertyModel.cs │ ├── MuescheliResponse.cs │ ├── ScanResult.cs │ └── DataElementScanRequest.cs │ ├── appsettings.Development.json │ ├── Clients │ ├── Interfaces │ │ ├── IMuescheliClient.cs │ │ └── IStorageClient.cs │ ├── StorageClient.cs │ └── MuescheliClient.cs │ ├── Configuration │ ├── GeneralSettings.cs │ ├── PlatformSettings.cs │ └── AppOwnerAzureStorageConfig.cs │ ├── appsettings.json │ ├── Repository │ ├── Interfaces │ │ ├── IBlobContainerClientProvider.cs │ │ └── IAppOwnerBlob.cs │ ├── AppOwnerBlobRepository.cs │ └── BlobContainerClientProvider.cs │ ├── Health │ └── HealthCheck.cs │ ├── Exceptions │ ├── MuescheliHttpException.cs │ ├── MuescheliScanResultException.cs │ └── PlatformHttpException.cs │ ├── Properties │ └── launchSettings.json │ ├── Controllers │ └── DataElementController.cs │ ├── Extensions │ └── HttpClientExtension.cs │ ├── Altinn.FileScan.csproj │ ├── Telemetry │ └── RequestFilterProcessor.cs │ └── Program.cs ├── renovate.json ├── Dockerfile ├── LICENSE ├── .gitattributes ├── stylecop.json ├── README.md ├── Altinn.FileScan.sln ├── .gitignore └── .editorconfig /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /.github/CODEOWNERS @altinn/team-altinn-studio 2 | -------------------------------------------------------------------------------- /test/k6/src/data/kattebilde.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinn/altinn-file-scan/main/test/k6/src/data/kattebilde.png -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/platform-org.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinn/altinn-file-scan/main/test/Altinn.FileScan.Tests/platform-org.pfx -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/jwtselfsignedcert.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinn/altinn-file-scan/main/test/Altinn.FileScan.Tests/jwtselfsignedcert.pfx -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .env 3 | .git 4 | .gitignore 5 | .vs 6 | .vscode 7 | docker-compose.yml 8 | docker-compose.*.yml 9 | */bin 10 | */obj 11 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Functions.Tests/platform-org.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altinn/altinn-file-scan/main/test/Altinn.FileScan.Functions.Tests/platform-org.pfx -------------------------------------------------------------------------------- /test/k6/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | networks: 4 | k6: 5 | 6 | services: 7 | k6: 8 | image: grafana/k6:1.4.2 9 | networks: 10 | - k6 11 | ports: 12 | - "6565:6565" 13 | volumes: 14 | - ./src:/src 15 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Properties/serviceDependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights" 5 | }, 6 | "storage1": { 7 | "type": "storage", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Properties/serviceDependencies.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "appInsights1": { 4 | "type": "appInsights.sdk" 5 | }, 6 | "storage1": { 7 | "type": "storage.emulator", 8 | "connectionId": "AzureWebJobsStorage" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | }, 8 | "ApplicationInsights": { 9 | "LogLevel": { 10 | "Default": "Warning", 11 | "System": "Warning", 12 | "Microsoft": "Warning" 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/Interfaces/IAccessToken.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Services.Interfaces 2 | { 3 | /// 4 | /// Interface for the access token service 5 | /// 6 | public interface IAccessToken 7 | { 8 | /// 9 | /// Generates an access token 10 | /// 11 | public Task Generate(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Models/BlobPropertyModel.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Models 2 | { 3 | /// 4 | /// Model type containing selected properties from Azure BlobProperties type 5 | /// 6 | public class BlobPropertyModel 7 | { 8 | /// 9 | /// Gets or sets when the blob was last modified 10 | /// 11 | public DateTimeOffset LastModified { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Configuration/CertificateResolverSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Functions.Configuration 2 | { 3 | /// 4 | /// Configuration object used to hold settings for the CertificateResolver. 5 | /// 6 | public class CertificateResolverSettings 7 | { 8 | /// 9 | /// Certificatee cache life time 10 | /// 11 | public int CacheCertLifetimeInSeconds { get; set; } = 3600; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Configuration/PlatformSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Functions.Configuration 2 | { 3 | /// 4 | /// Represents a set of configuration options when communicating with the platform API. 5 | /// 6 | public class PlatformSettings 7 | { 8 | /// 9 | /// Gets or sets the url for the FileScan API endpoint. 10 | /// 11 | public string ApiFileScanEndpoint { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AppOwnerAzureStorageConfig": { 9 | "AccountName": "devstoreaccount1", 10 | "AccountKey": "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", 11 | "StorageContainer": "appownerdata", 12 | "BlobEndPoint": "http://127.0.0.1:10000/devstoreaccount1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/assign-issue-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Auto Assign to Project 2 | permissions: 3 | contents: read 4 | issues: write 5 | 6 | on: 7 | issues: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | name: Add issue to Team altinn studio project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/add-to-project@main 17 | with: 18 | project-url: https://github.com/orgs/Altinn/projects/164 19 | github-token: ${{ secrets.ASSIGN_PROJECT_TOKEN }} 20 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Test description should be in the name of the test.", Scope = "module")] 9 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Functions.Tests/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Test description should be in the name of the test.", Scope = "module")] 9 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Models/MuescheliResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Models; 2 | 3 | /// 4 | /// Definition of the response object from the Muescheli service 5 | /// 6 | public class MuescheliResponse 7 | { 8 | /// 9 | /// Gets or sets the name of the file in the response object 10 | /// 11 | public string Filename { get; set; } 12 | 13 | /// 14 | /// Gets or sets the scan result in the the response object 15 | /// 16 | public ScanResult Result { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Clients/Interfaces/IFileScanClient.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Altinn.FileScan.Functions.Clients.Interfaces 4 | { 5 | /// 6 | /// Interface to FileScan API 7 | /// 8 | public interface IFileScanClient 9 | { 10 | /// 11 | /// Send dataElement for file scanning. 12 | /// 13 | /// DataElement to send 14 | Task PostDataElementScanRequest(string dataElementScanRequest); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Clients/Interfaces/IMuescheliClient.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | 3 | namespace Altinn.FileScan.Clients.Interfaces 4 | { 5 | /// 6 | /// Interface containing all client actions for the Altinn Muescheli Client 7 | /// 8 | public interface IMuescheliClient 9 | { 10 | /// 11 | /// Send a request to scan the file provided in the stream 12 | /// 13 | /// The malware scan result 14 | public Task ScanStream(Stream stream, string filename); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | }, 10 | "logLevel": { 11 | "default": "Information", 12 | "Function": "Information", 13 | "Altinn.FileScan.Functions.FileScanInbound": "Information" 14 | } 15 | }, 16 | "extensions": { 17 | "queues": { 18 | "maxPollingInterval": "00:00:00.500", 19 | "visibilityTimeout": "00:00:01", 20 | "maxDequeueCount": 5 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "PostgreSQLSettings": { 3 | "EnableDBConnection": "false" 4 | }, 5 | "GeneralSettings": { 6 | "Hostname": "localhost:5080", 7 | "OpenIdWellKnownEndpoint": "http://localhost:5101/authentication/api/v1/openid/", 8 | "JwtCookieName": "AltinnStudioRuntime" 9 | }, 10 | "PlatformSettings": { 11 | "ApiRegisterEndpoint": "http://localhost:5101/register/api/v1/", 12 | "ApiAuthorizationEndpoint": "http://localhost:5050/authorization/api/v1/", 13 | "AppsDomain": "apps.altinn.no", 14 | "SubscriptionCachingLifetimeInSeconds" : 3600 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Configuration/GeneralSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Configuration 2 | { 3 | /// 4 | /// Configuration object used to hold general settings for the events application. 5 | /// 6 | public class GeneralSettings 7 | { 8 | /// 9 | /// Open Id Connect Well known endpoint 10 | /// 11 | public string OpenIdWellKnownEndpoint { get; set; } 12 | 13 | /// 14 | /// Name of the cookie for where JWT is stored 15 | /// 16 | public string JwtCookieName { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Configuration/KeyVaultSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Functions.Configuration 2 | { 3 | /// 4 | /// Configuration object used to hold settings for the KeyVault. 5 | /// 6 | public class KeyVaultSettings 7 | { 8 | /// 9 | /// Uri to keyvault 10 | /// 11 | public string KeyVaultURI { get; set; } 12 | 13 | /// 14 | /// Name of the certificate secret 15 | /// 16 | public string PlatformCertSecretId { get; set; } = "platform-access-token-private-cert"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/Interfaces/IDataElement.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | 3 | namespace Altinn.FileScan.Services.Interfaces 4 | { 5 | /// 6 | /// Interface for all operations related to file scan of a a data element 7 | /// 8 | public interface IDataElement 9 | { 10 | /// 11 | /// Initiates the process to scan the provided data element for malware 12 | /// 13 | /// Returns true if the scan was completes successfully or not, regardles of scan result 14 | public Task Scan(DataElementScanRequest scanRequest); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Clients/Interfaces/IStorageClient.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Platform.Storage.Interface.Models; 2 | 3 | namespace Altinn.FileScan.Clients.Interfaces 4 | { 5 | /// 6 | /// Interface containing all client actions for the Altinn Storage Client 7 | /// 8 | public interface IStorageClient 9 | { 10 | /// 11 | /// Sends a request to Altinn Storage requesting to update the file scan status of a data element 12 | /// 13 | /// 14 | Task PatchFileScanStatus(string instanceId, string dataElementId, FileScanStatus fileScanStatus); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Services/Interfaces/ICertificateResolverService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | using System.Threading.Tasks; 3 | 4 | namespace Altinn.FileScan.Functions.Services.Interfaces 5 | { 6 | /// 7 | /// Interface to retrive certificate for access token 8 | /// 9 | public interface ICertificateResolverService 10 | { 11 | /// 12 | /// Returns certificate to be used for signing an access token 13 | /// 14 | /// The signing credentials 15 | public Task GetCertificateAsync(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Configuration/PlatformSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Configuration 2 | { 3 | /// 4 | /// Represents a set of configuration options when communicating with the Altinn Platform API. 5 | /// 6 | public class PlatformSettings 7 | { 8 | /// 9 | /// Gets or sets the url for the Storage API endpoint. 10 | /// 11 | public string ApiStorageEndpoint { get; set; } 12 | 13 | /// 14 | /// Sets or sets the url for the Muescheli API endpoint 15 | /// 16 | public string ApiMuescheliEndpoint { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/Interfaces/IPlatformKeyVault.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | 3 | namespace Altinn.FileScan.Services.Interfaces 4 | { 5 | /// 6 | /// Interface containing all actions for an Altinn Platform Azure Key Vault 7 | /// 8 | public interface IPlatformKeyVault 9 | { 10 | /// 11 | /// Gets the certificate from the given key vault. 12 | /// 13 | /// The id of the secret. 14 | /// The certificate value. 15 | Task GetCertificateAsync(string certId); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/k6/src/data/instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "instanceOwner": { 3 | "partyId": "replace", 4 | "personNumber": "replace" 5 | }, 6 | "org": "replace", 7 | "appId": "replace", 8 | "process": { 9 | "started": "2023-01-24T13:40:39.7765055Z", 10 | "startEvent": "StartEvent_1", 11 | "currentTask": { 12 | "flow": 2, 13 | "started": "2023-01-24T13:40:39.7857766Z", 14 | "elementId": "Task_1", 15 | "name": "Utfylling", 16 | "altinnTaskType": "data", 17 | "flowType": "CompleteCurrentMoveToNext" 18 | } 19 | }, 20 | "created": "2023-01-24T13:40:39.8120526Z", 21 | "createdBy": "20000000" 22 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", 6 | "QueueStorage": "AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;", 7 | "Platform:ApiFileScanEndpoint": "http://localhost:5080/filescan/api/v1/", 8 | "KeyVault:KeyVaultURI": "https://altinn-at21-kv.vault.azure.net/" 9 | } 10 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/Interfaces/IAppOwnerKeyVault.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Services.Interfaces 2 | { 3 | /// 4 | /// Interface containing all actions for an Altinn app owner Azure Key Vault 5 | /// 6 | public interface IAppOwnerKeyVault 7 | { 8 | /// 9 | /// Gets the value of a secret from the given key vault. 10 | /// 11 | /// The URI of the key vault to ask for secret. 12 | /// The id of the secret. 13 | /// The secret value. 14 | Task GetSecretAsync(string vaultUri, string secretId); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Altinn/renovate-config" 5 | ], 6 | "customManagers": [ 7 | { 8 | "customType": "regex", 9 | "description": "Manage Alpine OS versions in container image tags", 10 | "managerFilePatterns": [ 11 | "/Dockerfile/" 12 | ], 13 | "matchStrings": [ 14 | "(?:FROM\\s+)(?[\\S]+):(?[\\S]+)@(?sha256:[a-f0-9]+)" 15 | ], 16 | "versioningTemplate": "regex:^(?[\\S]*\\d+\\.\\d+(?:\\.\\d+)?(?:[\\S]*)?-alpine-?)(?\\d+)\\.(?\\d+)(?:\\.(?\\d+))?$", 17 | "datasourceTemplate": "docker" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/TelemetryInitializer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights.Channel; 2 | using Microsoft.ApplicationInsights.Extensibility; 3 | 4 | namespace Altinn.FileScan.Functions 5 | { 6 | /// 7 | /// Class that handles initialization of App Insights telemetry. 8 | /// 9 | public class TelemetryInitializer : ITelemetryInitializer 10 | { 11 | /// 12 | /// Initializer. 13 | /// 14 | /// The telemetry. 15 | public void Initialize(ITelemetry telemetry) 16 | { 17 | // set custom role name here 18 | telemetry.Context.Cloud.RoleName = "filescan-function"; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Models/ScanResult.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Models; 2 | 3 | /// 4 | /// Enum for possible responses from the malware scan as being presented by the Muescheli container. 5 | /// 6 | public enum ScanResult 7 | { 8 | /// 9 | /// The result of the scan is unknown. 10 | /// 11 | UNDEFINED, 12 | 13 | /// 14 | /// The scan didn't find any malware. 15 | /// 16 | OK, 17 | 18 | /// 19 | /// The scan identified malware. 20 | /// 21 | FOUND, 22 | 23 | /// 24 | /// An error occured. 25 | /// 26 | ERROR, 27 | 28 | /// 29 | /// An error occured. 30 | /// 31 | PARSE_ERROR 32 | } 33 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "GeneralSettings": { 9 | "OpenIdWellKnownEndpoint": "http://localhost:5101/authentication/api/v1/openid/", 10 | "JwtCookieName": "AltinnStudioRuntime" 11 | }, 12 | "PlatformSettings": { 13 | "ApiStorageEndpoint": "https://platform.at22.altinn.cloud/storage/api/v1/", 14 | "ApiMuescheliEndpoint": "http://locahost:8091/" 15 | }, 16 | "AppOwnerAzureStorageConfig": { 17 | "OrgStorageAccount": "{0}altinndevstrg01", 18 | "OrgStorageContainer": "{0}-dev-appsdata-blob-db" 19 | }, 20 | "AccessTokenSettings": { 21 | "TokenLifetimeInSeconds": 3600 22 | }, 23 | "AllowedHosts": "*" 24 | } 25 | -------------------------------------------------------------------------------- /test/k6/src/errorhandler.js: -------------------------------------------------------------------------------- 1 | import { Counter } from "k6/metrics"; 2 | import { fail } from "k6"; 3 | 4 | let ErrorCount = new Counter("errors"); 5 | 6 | //Adds a count to the error counter when value of success is false 7 | export function addErrorCount(success) { 8 | if (!success) { 9 | ErrorCount.add(1); 10 | } 11 | } 12 | 13 | /** 14 | * Stops k6 iteration when success is false and prints test name with response code 15 | * @param {String} testName 16 | * @param {boolean} success 17 | * @param {JSON} res 18 | */ 19 | export function stopIterationOnFail(testName, success, res) { 20 | if (!success && res != null) { 21 | fail(testName + ": Response code: " + res.status + ": Response message: " + JSON.stringify(res.body)); 22 | } else if (!success) { 23 | fail(testName); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Services/Interfaces/IKeyVaultService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | using System.Threading.Tasks; 3 | 4 | namespace Altinn.FileScan.Functions.Services.Interfaces 5 | { 6 | /// 7 | /// Interface for interacting with key vault 8 | /// 9 | public interface IKeyVaultService 10 | { 11 | /// 12 | /// Gets the value of a secret from the given key vault. 13 | /// 14 | /// The URI of the key vault to ask for secret. 15 | /// The id of the secret. 16 | /// The secret value. 17 | Task GetCertificateAsync(string vaultUri, string secretId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/AppOwnerKeyVaultService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Services.Interfaces; 2 | 3 | using Azure.Identity; 4 | using Azure.Security.KeyVault.Secrets; 5 | 6 | namespace Altinn.FileScan.Services 7 | { 8 | /// 9 | /// Implementation of default credentials are utilized for access 10 | /// 11 | public class AppOwnerKeyVaultService : IAppOwnerKeyVault 12 | { 13 | /// 14 | public async Task GetSecretAsync(string vaultUri, string secretId) 15 | { 16 | SecretClient secretClient = new(new Uri(vaultUri), new DefaultAzureCredential()); 17 | 18 | KeyVaultSecret secret = await secretClient.GetSecretAsync(secretId); 19 | 20 | return secret.Value; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Mocks/PublicSigningKeyProviderMock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Security.Cryptography.X509Certificates; 5 | using System.Threading.Tasks; 6 | 7 | using Altinn.Common.AccessToken.Services; 8 | 9 | using Microsoft.IdentityModel.Tokens; 10 | 11 | namespace Altinn.FileScan.Tests.Mocks 12 | { 13 | public class PublicSigningKeyProviderMock : IPublicSigningKeyProvider 14 | { 15 | public Task> GetSigningKeys(string issuer) 16 | { 17 | List signingKeys = new(); 18 | 19 | X509Certificate2 cert = X509CertificateLoader.LoadCertificateFromFile($"{issuer}-org.pem"); 20 | 21 | SecurityKey key = new X509SecurityKey(cert); 22 | 23 | signingKeys.Add(key); 24 | 25 | return Task.FromResult(signingKeys.AsEnumerable()); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Repository/Interfaces/IBlobContainerClientProvider.cs: -------------------------------------------------------------------------------- 1 | using Azure.Storage.Blobs; 2 | 3 | namespace Altinn.FileScan.Repository.Interfaces 4 | { 5 | /// 6 | /// This interface describes a component able to obtain and invalidate a blob client for operations on an Azure storage account. 7 | /// 8 | public interface IBlobContainerClientProvider 9 | { 10 | /// 11 | /// Get the container client to access blobs in the storage account for given application owner. 12 | /// 13 | /// The application owner id. 14 | /// Alternate number to append to container name 15 | /// The container client to use when accessing the application owner storage account. 16 | BlobContainerClient GetBlobContainerClient(string org, int? storageAccountNumber); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Health/HealthCheck.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | using Microsoft.Extensions.Diagnostics.HealthChecks; 4 | 5 | namespace Altinn.FileScan.Health 6 | { 7 | /// 8 | /// Health check service configured in startup 9 | /// Listen to 10 | /// 11 | [ExcludeFromCodeCoverage] 12 | public class HealthCheck : IHealthCheck 13 | { 14 | /// 15 | /// Verifies the healht status 16 | /// 17 | /// The healtcheck context 18 | /// The cancellationtoken 19 | /// 20 | public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) 21 | { 22 | return Task.FromResult( 23 | HealthCheckResult.Healthy("A healthy result.")); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:9.0.307-alpine3.22@sha256:512f8347b0d2f9848f099a8c31be07286955ceea337cadb1114057ed0b15862f AS build 2 | 3 | # Copy event backend 4 | COPY src/Altinn.FileScan ./Altinn.FileScan 5 | WORKDIR Altinn.FileScan/ 6 | 7 | 8 | # Build and publish 9 | RUN dotnet build Altinn.FileScan.csproj -c Release -o /app_output 10 | RUN dotnet publish Altinn.FileScan.csproj -c Release -o /app_output 11 | 12 | FROM mcr.microsoft.com/dotnet/aspnet:9.0.11-alpine3.22@sha256:be36809e32840cf9fcbf1a3366657c903e460d3c621d6593295a5e5d02268a0d AS final 13 | EXPOSE 5200 14 | WORKDIR /app 15 | COPY --from=build /app_output . 16 | 17 | # setup the user and group 18 | # the user will have no password, using shell /bin/false and using the group dotnet 19 | RUN addgroup -g 3000 dotnet && adduser -u 1000 -G dotnet -D -s /bin/false dotnet 20 | # update permissions of files if neccessary before becoming dotnet user 21 | USER dotnet 22 | RUN mkdir /tmp/logtelemetry 23 | 24 | ENTRYPOINT ["dotnet", "Altinn.FileScan.dll"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Altinn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # General setting that applies Git's binary detection for file-types not specified below 2 | # Meaning, for 'text-guessed' files: 3 | # use normalization (convert crlf -> lf on commit, i.e. use `text` setting) 4 | # & do unspecified diff behavior (if file content is recognized as text & filesize < core.bigFileThreshold, do text diff on file changes) 5 | * text=auto 6 | 7 | 8 | # Override with explicit specific settings for known and/or likely text files in our repo that should be normalized 9 | # where diff{=optional_pattern} means "do text diff {with specific text pattern} and -diff means "don't do text diffs". 10 | # Unspecified diff behavior is decribed above 11 | *.cer text -diff 12 | *.cmd text 13 | *.cs text diff=csharp 14 | *.csproj text 15 | *.css text diff=css 16 | Dockerfile text 17 | *.json text 18 | *.md text diff=markdown 19 | *.msbuild text 20 | *.pem text -diff 21 | *.ps1 text 22 | *.sln text 23 | *.yaml text 24 | *.yml text 25 | 26 | # Files that should be treated as binary ('binary' is a macro for '-text -diff', i.e. "don't normalize or do text diff on content") 27 | *.jpeg binary 28 | *.pfx binary 29 | *.png binary -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/platform-org.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDAzCCAeugAwIBAgIJANTdO8o3I8x5MA0GCSqGSIb3DQEBCwUAMA4xDDAKBgNV 3 | BAMTA3R0ZDAeFw0yMDA1MjUxMjIxMzdaFw0zMDA1MjQxMjIxMzdaMA4xDDAKBgNV 4 | BAMTA3R0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMcfTsXwwLyC 5 | UkIz06eadWJvG3yrzT+ZB2Oy/WPaZosDnPcnZvCDueN+oy0zTx5TyH5gCi1FvzX2 6 | 7G2eZEKwQaRPv0yuM+McHy1rXxMSOlH/ebP9KJj3FDMUgZl1DCAjJxSAANdTwdrq 7 | ydVg1Crp37AQx8IIEjnBhXsfQh1uPGt1XwgeNyjl00IejxvQOPzd1CofYWwODVtQ 8 | l3PKn1SEgOGcB6wuHNRlnZPCIelQmqxWkcEZiu/NU+kst3NspVUQG2Jf2AF8UWgC 9 | rnrhMQR0Ra1Vi7bWpu6QIKYkN9q0NRHeRSsELOvTh1FgDySYJtNd2xDRSf6IvOiu 10 | tSipl1NZlV0CAwEAAaNkMGIwIAYDVR0OAQH/BBYEFIwq/KbSMzLETdo9NNxj0rz4 11 | qMqVMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMCAGA1UdJQEB/wQWMBQG 12 | CCsGAQUFBwMBBggrBgEFBQcDAjANBgkqhkiG9w0BAQsFAAOCAQEAE56UmH5gEYbe 13 | 1kVw7nrfH0R9FyVZGeQQWBn4/6Ifn+eMS9mxqe0Lq74Ue1zEzvRhRRqWYi9JlKNf 14 | 7QQNrc+DzCceIa1U6cMXgXKuXquVHLmRfqvKHbWHJfIkaY8Mlfy++77UmbkvIzly 15 | T1HVhKKp6Xx0r5koa6frBh4Xo/vKBlEyQxWLWF0RPGpGErnYIosJ41M3Po3nw3lY 16 | f7lmH47cdXatcntj2Ho/b2wGi9+W29teVCDfHn2/0oqc7K0EOY9c2ODLjUvQyPZR 17 | OD2yykpyh9x/YeYHFDYdLDJ76/kIdxN43kLU4/hTrh9tMb1PZF+/4DshpAlRoQuL 18 | o8I8avQm/A== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Configuration/AppOwnerAzureStorageConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Configuration 2 | { 3 | /// 4 | /// Settings for Azure storage 5 | /// 6 | public class AppOwnerAzureStorageConfig 7 | { 8 | /// 9 | /// storage account name 10 | /// 11 | public string AccountName { get; set; } 12 | 13 | /// 14 | /// storage account key 15 | /// 16 | public string AccountKey { get; set; } 17 | 18 | /// 19 | /// name of the storage container in the storage account 20 | /// 21 | public string StorageContainer { get; set; } 22 | 23 | /// 24 | /// url for the blob end point 25 | /// 26 | public string BlobEndPoint { get; set; } 27 | 28 | /// 29 | /// name of app owner storage account 30 | /// 31 | public string OrgStorageAccount { get; set; } 32 | 33 | /// 34 | /// name of storage container in app owner storage account 35 | /// 36 | public string OrgStorageContainer { get; set; } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Exceptions/MuescheliHttpException.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace Altinn.FileScan.Exceptions; 4 | 5 | /// 6 | /// An exception class related to non expected http response from the Muescheli Service 7 | /// 8 | public class MuescheliHttpException : Exception 9 | { 10 | /// 11 | /// The http response message that generated the exception 12 | /// 13 | public HttpResponseMessage Response { get; } 14 | 15 | /// 16 | /// Creates a new combining the response message and 17 | /// 18 | public static async Task CreateAsync(HttpStatusCode statusCode, HttpResponseMessage response) 19 | { 20 | string responseMessage = await response.Content.ReadAsStringAsync(); 21 | 22 | string message = $"{statusCode} - {responseMessage}"; 23 | return new MuescheliHttpException(response, message); 24 | } 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | public MuescheliHttpException(HttpResponseMessage response, string message) : base(message) 30 | { 31 | Response = response; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:27047", 8 | "sslPort": 44326 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "filescan/swagger", 17 | "applicationUrl": "http://localhost:5200", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "filescan/swagger", 27 | "applicationUrl": "https://localhost:7200;http://localhost:5200", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "filescan/swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Controllers/DataElementController.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | using Altinn.FileScan.Services.Interfaces; 3 | 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Altinn.FileScan.Controllers 8 | { 9 | /// 10 | /// Controller containing all actions related to data element 11 | /// 12 | [Route("filescan/api/v1/dataelement")] 13 | [ApiController] 14 | public class DataElementController : ControllerBase 15 | { 16 | private readonly IDataElement _dataElement; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | public DataElementController(IDataElement dataElement) 22 | { 23 | _dataElement = dataElement; 24 | } 25 | 26 | /// 27 | /// Post a data element for malware scan 28 | /// 29 | [Authorize(Policy = "PlatformAccess")] 30 | [ProducesResponseType(StatusCodes.Status200OK)] 31 | [HttpPost] 32 | public async Task Scan(DataElementScanRequest scanRequest) 33 | { 34 | await _dataElement.Scan(scanRequest); 35 | 36 | return Ok(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/container-scan.yml: -------------------------------------------------------------------------------- 1 | name: FileScan Scan 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | schedule: 7 | - cron: '0 8 * * 1,4' 8 | push: 9 | branches: [ main ] 10 | paths: 11 | - 'src/Altinn.FileScan/**' 12 | - 'Dockerfile' 13 | pull_request: 14 | branches: [ main ] 15 | types: [opened, synchronize, reopened] 16 | paths: 17 | - 'src/Altinn.FileScan/**' 18 | - 'Dockerfile' 19 | jobs: 20 | scan: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 24 | - name: Build the Docker image 25 | run: docker build . --tag altinn-filescan:${{github.sha}} 26 | 27 | - name: Run Trivy vulnerability scanner 28 | uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 29 | with: 30 | image-ref: 'altinn-filescan:${{ github.sha }}' 31 | format: 'table' 32 | exit-code: '1' 33 | ignore-unfixed: true 34 | vuln-type: 'os,library' 35 | severity: 'CRITICAL,HIGH' 36 | env: 37 | TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db,aquasec/trivy-db,ghcr.io/aquasecurity/trivy-db 38 | TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db,aquasec/trivy-java-db,ghcr.io/aquasecurity/trivy-java-db 39 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Extensions/HttpClientExtension.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Extensions 2 | { 3 | /// 4 | /// This extension is created to make it easy to add a bearer token to a HttpRequests. 5 | /// 6 | public static class HttpClientExtension 7 | { 8 | /// 9 | /// Extension that add authorization header to request 10 | /// 11 | /// The HttpClient 12 | /// The request Uri 13 | /// The http content 14 | /// The platformAccess tokens 15 | /// A HttpResponseMessage 16 | public static Task PutAsync(this HttpClient httpClient, string requestUri, HttpContent content, string platformAccessToken) 17 | { 18 | HttpRequestMessage request = new(HttpMethod.Put, new Uri(requestUri, UriKind.Relative)) 19 | { 20 | Content = content 21 | }; 22 | 23 | if (!string.IsNullOrEmpty(platformAccessToken)) 24 | { 25 | request.Headers.Add("PlatformAccessToken", platformAccessToken); 26 | } 27 | 28 | return httpClient.SendAsync(request, CancellationToken.None); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Extentions/HttpClientExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Altinn.FileScan.Functions.Extensions 7 | { 8 | /// 9 | /// This extension is created to make it easy to add a bearer token to a HttpRequests. 10 | /// 11 | public static class HttpClientExtension 12 | { 13 | /// 14 | /// Extension that add authorization header to request 15 | /// 16 | /// The HttpClient 17 | /// The request Uri 18 | /// The http content 19 | /// The platformAccess tokens 20 | /// A HttpResponseMessage 21 | public static Task PostAsync(this HttpClient httpClient, string requestUri, HttpContent content, string platformAccessToken) 22 | { 23 | HttpRequestMessage request = new(HttpMethod.Post, new Uri(requestUri, UriKind.Relative)) 24 | { 25 | Content = content 26 | }; 27 | 28 | request.Headers.Add("PlatformAccessToken", platformAccessToken); 29 | return httpClient.SendAsync(request, CancellationToken.None); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Mocks/Authentication/JwtCookiePostConfigureOptionsStub.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using AltinnCore.Authentication.JwtCookie; 4 | 5 | using Microsoft.AspNetCore.Authentication.Cookies; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Altinn.FileScan.Tests.Mocks.Authentication 9 | { 10 | /// 11 | /// Represents a stub for the class to be used in integration tests. 12 | /// 13 | public class JwtCookiePostConfigureOptionsStub : IPostConfigureOptions 14 | { 15 | /// 16 | public void PostConfigure(string name, JwtCookieOptions options) 17 | { 18 | if (string.IsNullOrEmpty(options.JwtCookieName)) 19 | { 20 | options.JwtCookieName = JwtCookieDefaults.CookiePrefix + name; 21 | } 22 | 23 | options.CookieManager ??= new ChunkingCookieManager(); 24 | 25 | if (!string.IsNullOrEmpty(options.MetadataAddress)) 26 | { 27 | if (!options.MetadataAddress.EndsWith("/", StringComparison.Ordinal)) 28 | { 29 | options.MetadataAddress += "/"; 30 | } 31 | } 32 | 33 | options.MetadataAddress += ".well-known/openid-configuration"; 34 | options.ConfigurationManager = new ConfigurationManagerStub(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Exceptions/MuescheliScanResultException.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | 3 | namespace Altinn.FileScan.Exceptions; 4 | 5 | /// 6 | /// An exception class related to non expected http response from the Muescheli Service 7 | /// 8 | public class MuescheliScanResultException : Exception 9 | { 10 | /// 11 | /// The id of the data element related to the exception 12 | /// 13 | public string DataElementId { get; } 14 | 15 | /// 16 | /// The scan result that generated the exception 17 | /// 18 | public ScanResult ScanResult { get; } 19 | 20 | /// 21 | /// Creates a new combining the response message and 22 | /// 23 | public static MuescheliScanResultException Create(string dataElementId, ScanResult result) 24 | { 25 | string message = $"Muescheli scan returned result code `{result}` for data element with id {dataElementId}"; 26 | return new MuescheliScanResultException(dataElementId, result, message); 27 | } 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | public MuescheliScanResultException(string dataElementId, ScanResult result, string message) : base(message) 33 | { 34 | DataElementId = dataElementId; 35 | ScanResult = result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Repository/Interfaces/IAppOwnerBlob.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | 3 | namespace Altinn.FileScan.Repository.Interfaces 4 | { 5 | /// 6 | /// Interface containing all repository actions for an Altinn app owner Azure blob repository 7 | /// 8 | public interface IAppOwnerBlob 9 | { 10 | /// 11 | /// Retrieves a data blob as a stream from an app owner storage account within Altinn 12 | /// 13 | /// The short name of the organisation 14 | /// Full path to the blob within a storage account 15 | /// Alternate number to append to container name 16 | /// The blob as a streamW 17 | public Task GetBlob(string org, string blobPath, int? storageAccountNumber); 18 | 19 | /// 20 | /// Retrieves the blob metadata as stored by Blob Storage 21 | /// 22 | /// The short name of the organisation 23 | /// Full path to the blob within a storage account 24 | /// Alternate number to append to container name 25 | /// Blob properties object 26 | public Task GetBlobProperties(string org, string blobPath, int? storageAccountNumber); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/JWTValidationCert.cer: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID/zCCAuegAwIBAgIQF2ov3ZZUmJVKtoz0a1fabDANBgkqhkiG9w0BAQsFADB/ 3 | MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHY29udG9zbzEU 4 | MBIGCgmSJomT8ixkARkWBGNvcnAxFTATBgNVBAsMDFVzZXJBY2NvdW50czEiMCAG 5 | A1UEAwwZQWx0aW5uIFBsYXRmb3JtIFVuaXQgdGVzdDAgFw0yMDA0MTQwOTMwMTda 6 | GA8yMTIwMDQxNDA5NDAxOFowfzETMBEGCgmSJomT8ixkARkWA2NvbTEXMBUGCgmS 7 | JomT8ixkARkWB2NvbnRvc28xFDASBgoJkiaJk/IsZAEZFgRjb3JwMRUwEwYDVQQL 8 | DAxVc2VyQWNjb3VudHMxIjAgBgNVBAMMGUFsdGlubiBQbGF0Zm9ybSBVbml0IHRl 9 | c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDCAKc+q5jbYFyQFxM1 10 | xU3v0N477ppnMu03K8qlEkX0+yffRHcR1I0Kku8yg1S+LQjeqh1K42b270myKiIt 11 | vxeuNnanRwdehTZthThembr8RXoGcmzaXfMet7NVDgUa7gNzPXbqjhTFdyWoZzeU 12 | X6TWTgFtciTs5M1F50H+3nieGKX2dvLUIEXWFO7yevj9bqtI8k0b66eLgBjchnjW 13 | 8B7oYOFZW44VDDnqQrvFJ9aMQ44FfLAWWLcy6nBzcDdK+Z+yq9FNVgduyl0J7vRo 14 | 3UtcVazLUvmDdwASLIB3IwB7YmT6fuOyM+6eyw5F1CdjXbc/bhop0pCDY1aAEsZA 15 | CjT9AgMBAAGjdTBzMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcD 16 | AjAtBgNVHREEJjAkoCIGCisGAQQBgjcUAgOgFAwSdGVzdEBhbHRpbm4uc3R1ZGlv 17 | MB0GA1UdDgQWBBTv8Cpf5J7nfmGds20LU/J3bg05XTANBgkqhkiG9w0BAQsFAAOC 18 | AQEAahWeu6ymaiJe9+LiMlQwNsUIV4KaLX+jCsRyF1jUJ0C13aFALGM4k9svqqXR 19 | DzBdCXXr0c1E+Ks3sCwBLfK5yj5fTI+pL26ceEmHahcVyLvzEBljtNb4FnGFs92P 20 | CH0NuCz45hQ2O9/Tv4cZAdgledTznJTKzzQNaF8M6iINmP6sf4kOg0BQx0K71K4f 21 | 7j2oQvYKiT7Zv1e83cdk9pS4ihDe+ZWYiGUM/IuaXNPl6OzVk4rY88PZJAoz7q33 22 | rYjlT+zkcl3dzTc3E0CWzbIWjhaXCRWvlI44cLRtdpmPqJUHI6a/tcGwNb5vWiT4 23 | YfZJ0EZ2iSRQlpU3+jMs8Ci2AA== 24 | -----END CERTIFICATE----- 25 | -------------------------------------------------------------------------------- /test/k6/readme.md: -------------------------------------------------------------------------------- 1 | ## k6 test project for automated tests 2 | 3 | # Getting started 4 | 5 | 6 | Install pre-requisites 7 | ## Install k6 8 | 9 | *We recommend running the tests through a docker container.* 10 | 11 | From the command line: 12 | 13 | > docker pull grafana/k6 14 | 15 | 16 | Further information on [installing k6 for running in docker is available here.](https://k6.io/docs/get-started/installation/#docker) 17 | 18 | 19 | Alternatively, it is possible to run the tests directly on your machine as well. 20 | 21 | [General installation instructions are available here.](https://k6.io/docs/get-started/installation/) 22 | 23 | 24 | ## Running tests 25 | 26 | All tests are defined in `src/tests` and in the top of each test file an example of the cmd to run the test is available. 27 | 28 | The command should be run from the root of the k6 folder. 29 | 30 | >$> cd /altinn-file-scan/test/k6 31 | 32 | Run test suite by specifying filename. 33 | 34 | For example: 35 | 36 | >$> docker compose run k6 run /src/tests/end2end.js -e tokenGeneratorUserName=autotest -e tokenGeneratorUserPwd=*** -e env=at23 -e subskey=*** -e partyId=*** -e personNumber=*** -e org=ttd -e app=filescan-end-to-end -e userId=*** 37 | 38 | 39 | The comand consists of three sections 40 | 41 | `docker compose run` to run the test in a docker container 42 | 43 | `k6 run {path to test file}` pointing to the test file you want to run e.g. `/src/test/events.js` 44 | 45 | 46 | `-e tokenGeneratorUserName=*** -e tokenGeneratorUserPwd=*** -e env=***` all environment variables that should be included in the request. 47 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Exceptions/PlatformHttpException.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Exceptions; 2 | 3 | /// 4 | /// Exception class to hold exceptions when talking to the platform REST services 5 | /// 6 | public class PlatformHttpException : Exception 7 | { 8 | /// 9 | /// Responsible for holding an http request exception towards platform (storage). 10 | /// 11 | public HttpResponseMessage Response { get; } 12 | 13 | /// 14 | /// Create a new by reading the 15 | /// content asynchronously. 16 | /// 17 | /// The to read. 18 | /// A new . 19 | public static async Task CreateAsync(HttpResponseMessage response) 20 | { 21 | string content = await response.Content.ReadAsStringAsync(); 22 | string message = $"{(int)response.StatusCode} - {response.ReasonPhrase} - {content}"; 23 | 24 | return new PlatformHttpException(response, message); 25 | } 26 | 27 | /// 28 | /// Copy the response for further investigations 29 | /// 30 | /// the response 31 | /// A description of the cause of the exception. 32 | public PlatformHttpException(HttpResponseMessage response, string message) : base(message) 33 | { 34 | this.Response = response; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/FileScanInbound.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Altinn.FileScan.Functions.Clients.Interfaces; 3 | using Microsoft.Azure.Functions.Worker; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Altinn.FileScan.Functions 7 | { 8 | /// 9 | /// Azure Function class. 10 | /// 11 | public class FileScanInbound 12 | { 13 | private readonly IFileScanClient _fileScanClient; 14 | private readonly ILogger _logger; 15 | 16 | /// 17 | /// Initializes a new instance of the class. 18 | /// 19 | /// FileScanClient 20 | /// ILoggerFactory 21 | public FileScanInbound(IFileScanClient fileScanClient, ILoggerFactory loggerFactory) 22 | { 23 | _fileScanClient = fileScanClient; 24 | _logger = loggerFactory.CreateLogger(); 25 | } 26 | 27 | /// 28 | /// Retrieves dataElements from file-scna-inbound queue and send to FileScans rest-api 29 | /// 30 | [Function("FileScanInbound")] 31 | public async Task Run([QueueTrigger("file-scan-inbound", Connection = "QueueStorage")] string dataElementScanRequest) 32 | { 33 | _logger.LogInformation("C# Queue trigger function processed: {dataElement}", dataElementScanRequest); 34 | await _fileScanClient.PostDataElementScanRequest(dataElementScanRequest); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Models/DataElementScanRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Altinn.FileScan.Models 2 | { 3 | /// 4 | /// This class represents a request to perform a file scan of an Altinn Data Element. 5 | /// 6 | public class DataElementScanRequest 7 | { 8 | /// 9 | /// Gets or sets the unique id of the data element. 10 | /// 11 | public string DataElementId { get; set; } 12 | 13 | /// 14 | /// Gets or sets the unique id of the parent instance of the data element. 15 | /// 16 | /// 17 | /// The instance id contains both the instance owner party id and the unique instance guid. 18 | /// 19 | public string InstanceId { get; set; } 20 | 21 | /// 22 | /// Gets or sets the name of the data element (file) 23 | /// 24 | public string Filename { get; set; } 25 | 26 | /// 27 | /// Gets or sets the time when blob was saved. 28 | /// 29 | public DateTimeOffset Timestamp { get; set; } 30 | 31 | /// 32 | /// Gets or sets the path to blob storage. 33 | /// 34 | public string BlobStoragePath { get; set; } 35 | 36 | /// 37 | /// Gets or sets the application owner identifier 38 | /// 39 | public string Org { get; set; } 40 | 41 | /// 42 | /// Gets or sets an optional alternate number to append to the storage account name 43 | /// 44 | public int? StorageAccountNumber { get; set; } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/use-case-PROD.yaml: -------------------------------------------------------------------------------- 1 | name: Use Case - PROD 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '*/15 * * * *' 9 | 10 | jobs: 11 | PROD: 12 | environment: PROD 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 16 | - name: Run use case tests 17 | run: | 18 | cd test/k6 19 | docker compose run k6 run /src/tests/end2end.js -e subskey=${{ secrets.APIM_SUBSKEY }} -e env=${{ vars.ENV }} -e org=${{ vars.ORG }} -e app=${{ vars.APP }} -e userId=${{ secrets.USER_ID }} -e personNumber=${{ secrets.PERSON_NUMBER }} -e partyId=${{ secrets.PARTY_ID }} -e username=${{ secrets.USERNAME }} -e password=${{ secrets.PASSWORD }} 20 | 21 | report-status: 22 | name: Report status 23 | runs-on: ubuntu-latest 24 | needs: [PROD] 25 | if: always() && contains(join(needs.*.result, ','), 'failure') 26 | steps: 27 | - name: Build failure report 28 | run: | 29 | report=" :warning: FileScan use case test failure in production :warning: \n" 30 | report+="Workflow available here: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" 31 | echo "stepreport="$report >> $GITHUB_ENV 32 | - name: Report failure to Slack 33 | id: slack 34 | uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 35 | with: 36 | webhook-type: incoming-webhook 37 | webhook: ${{ secrets.SLACK_WEBHOOK_URL_PROD }} 38 | payload: | 39 | { 40 | "text": "${{ env.stepreport }}" 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Program.cs: -------------------------------------------------------------------------------- 1 | using Altinn.Common.AccessTokenClient.Services; 2 | using Altinn.FileScan.Functions; 3 | using Altinn.FileScan.Functions.Clients; 4 | using Altinn.FileScan.Functions.Clients.Interfaces; 5 | using Altinn.FileScan.Functions.Configuration; 6 | using Altinn.FileScan.Functions.Services; 7 | using Altinn.FileScan.Functions.Services.Interfaces; 8 | 9 | using Microsoft.ApplicationInsights.Extensibility; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Hosting; 13 | 14 | var host = new HostBuilder() 15 | .ConfigureFunctionsWorkerDefaults() 16 | .ConfigureServices(s => 17 | { 18 | s.AddOptions() 19 | .Configure((settings, configuration) => 20 | { 21 | configuration.GetSection("Platform").Bind(settings); 22 | }); 23 | s.AddOptions() 24 | .Configure((settings, configuration) => 25 | { 26 | configuration.GetSection("KeyVault").Bind(settings); 27 | }); 28 | s.AddOptions() 29 | .Configure((settings, configuration) => 30 | { 31 | configuration.GetSection("CertificateResolver").Bind(settings); 32 | }); 33 | 34 | s.AddSingleton(); 35 | s.AddSingleton(); 36 | s.AddSingleton(); 37 | s.AddSingleton(); 38 | s.AddHttpClient(); 39 | }) 40 | .Build(); 41 | 42 | host.Run(); 43 | -------------------------------------------------------------------------------- /stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | // ACTION REQUIRED: This file was automatically added to your project, but it 3 | // will not take effect until additional steps are taken to enable it. See the 4 | // following page for additional information: 5 | // 6 | // https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/EnableConfiguration.md 7 | 8 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 9 | "settings": { 10 | "documentationRules": { 11 | "companyName": "PlaceholderCompany" 12 | }, 13 | "orderingRules": { 14 | "usingDirectivesPlacement": "outsideNamespace", 15 | "systemUsingDirectivesFirst": true, 16 | "blankLinesBetweenUsingGroups": "allow" 17 | }, 18 | "namingRules": { 19 | "allowCommonHungarianPrefixes": true, 20 | "allowedHungarianPrefixes": [ 21 | "as", 22 | "d", 23 | "db", 24 | "dn", 25 | "do", 26 | "dr", 27 | "ds", 28 | "dt", 29 | "e", 30 | "e2", 31 | "er", 32 | "f", 33 | "fs", 34 | "go", 35 | "id", 36 | "if", 37 | "in", 38 | "ip", 39 | "is", 40 | "js", 41 | "li", 42 | "my", 43 | "no", 44 | "ns", 45 | "on", 46 | "or", 47 | "pi", 48 | "pv", 49 | "sa", 50 | "sb", 51 | "se", 52 | "si", 53 | "so", 54 | "sp", 55 | "tc", 56 | "to", 57 | "tr", 58 | "ui", 59 | "un", 60 | "wf", 61 | "ws", 62 | "x", 63 | "y", 64 | "j", 65 | "js" 66 | ] 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/use-case-TT02.yaml: -------------------------------------------------------------------------------- 1 | name: Use Case - TT02 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '*/15 * * * *' 9 | 10 | jobs: 11 | TT02: 12 | environment: TT02 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 16 | - name: Run use case tests 17 | run: | 18 | cd test/k6 19 | docker compose run k6 run /src/tests/end2end.js -e subskey=${{ secrets.APIM_SUBSKEY }} -e env=${{ vars.ENV }} -e org=${{ vars.ORG }} -e app=${{ vars.APP }} -e userId=${{ secrets.USER_ID }} -e personNumber=${{ secrets.PERSON_NUMBER }} -e tokenGeneratorUserName=${{ secrets.TOKENGENERATOR_USERNAME }} -e tokenGeneratorUserPwd=${{ secrets.TOKENGENERATOR_USERPASSWORD }} -e partyId=${{ secrets.PARTY_ID }} 20 | 21 | report-status: 22 | name: Report status 23 | runs-on: ubuntu-latest 24 | needs: [TT02] 25 | if: always() && contains(join(needs.*.result, ','), 'failure') 26 | steps: 27 | - name: Build failure report 28 | run: | 29 | report=":warning: FileScan use case test failure in TT02 :warning: \n" 30 | report+="Workflow available here: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" 31 | echo "stepreport="$report >> $GITHUB_ENV 32 | 33 | - name: Report failure to Slack 34 | id: slack 35 | uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 36 | with: 37 | webhook-type: incoming-webhook 38 | webhook: ${{ secrets.SLACK_WEBHOOK_URL_PROD }} 39 | payload: | 40 | { 41 | "text": "${{ env.stepreport }}" 42 | } 43 | 44 | -------------------------------------------------------------------------------- /test/k6/src/config.js: -------------------------------------------------------------------------------- 1 | // Baseurls for platform 2 | export var baseUrls = { 3 | at21: "at21.altinn.cloud", 4 | at22: "at22.altinn.cloud", 5 | at23: "at23.altinn.cloud", 6 | at24: "at24.altinn.cloud", 7 | yt01: "yt01.altinn.cloud", 8 | tt02: "tt02.altinn.no", 9 | prod: "altinn.no" 10 | }; 11 | 12 | // Auth cookie names in the different environments. NB: Must be updated until changes 13 | // are rolled out to all environments 14 | export var authCookieNames = { 15 | at21: '.AspxAuthCloud', 16 | at22: '.AspxAuthCloud', 17 | at23: '.AspxAuthCloud', 18 | at24: '.AspxAuthCloud', 19 | tt02: '.AspxAuthTT02', 20 | yt01: '.AspxAuthYt', 21 | prod: '.AspxAuthProd', 22 | }; 23 | 24 | //Get values from environment 25 | const environment = __ENV.env.toLowerCase(); 26 | export let baseUrl = baseUrls[environment]; 27 | export let authCookieName = authCookieNames[environment]; 28 | 29 | //AltinnTestTools 30 | export var tokenGenerator = { 31 | getEnterpriseToken: 32 | "https://altinn-testtools-token-generator.azurewebsites.net/api/GetEnterpriseToken", 33 | getPersonalToken: 34 | "https://altinn-testtools-token-generator.azurewebsites.net/api/GetPersonalToken" 35 | }; 36 | 37 | // Platform Storage 38 | export var platformStorage = { 39 | instances: "https://platform." + baseUrl + "/storage/api/v1/instances/", 40 | }; 41 | 42 | // Platform Authentication 43 | export var platformAuthentication={ 44 | authentication: 'https://platform.' + baseUrl + '/authentication/api/v1/authentication', 45 | refresh: 'https://platform.' + baseUrl + '/authentication/api/v1/refresh', 46 | } 47 | 48 | // Altinn App 49 | export var app = { 50 | ttd: "https://ttd.apps."+baseUrl+"/ttd/", 51 | } 52 | 53 | export var sbl = { 54 | authenticationWithPassword: 'https://' + baseUrl + '/api/authentication/authenticatewithpassword', 55 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Services/KeyVaultService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Threading.Tasks; 5 | using Altinn.FileScan.Functions.Services.Interfaces; 6 | using Azure; 7 | using Azure.Identity; 8 | using Azure.Security.KeyVault.Certificates; 9 | 10 | namespace Altinn.FileScan.Functions.Services 11 | { 12 | /// 13 | /// Wrapper implementation for a KeyVaultClient. The wrapped client is created with a principal obtained through configuration. 14 | /// 15 | /// This class is excluded from code coverage because it has no logic to be tested. 16 | [ExcludeFromCodeCoverage] 17 | public class KeyVaultService : IKeyVaultService 18 | { 19 | /// 20 | public async Task GetCertificateAsync(string vaultUri, string secretId) 21 | { 22 | CertificateClient certificateClient = new(new Uri(vaultUri), new DefaultAzureCredential()); 23 | AsyncPageable certificatePropertiesPage = certificateClient.GetPropertiesOfCertificateVersionsAsync(secretId); 24 | await foreach (CertificateProperties certificateProperties in certificatePropertiesPage) 25 | { 26 | if (certificateProperties.Enabled == true && 27 | (certificateProperties.ExpiresOn == null || certificateProperties.ExpiresOn >= DateTime.UtcNow)) 28 | { 29 | X509Certificate2 cert = await certificateClient.DownloadCertificateAsync(certificateProperties.Name, certificateProperties.Version); 30 | return cert; 31 | } 32 | } 33 | 34 | return null; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Repository/AppOwnerBlobRepository.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Models; 2 | using Altinn.FileScan.Repository.Interfaces; 3 | 4 | using Azure.Storage.Blobs.Models; 5 | 6 | namespace Altinn.FileScan.Repository 7 | { 8 | /// 9 | /// Implementation of IAppOwnerBlob towards Azure Storage 10 | /// 11 | public class AppOwnerBlobRepository : IAppOwnerBlob 12 | { 13 | private readonly IBlobContainerClientProvider _containerClientProvider; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | public AppOwnerBlobRepository(IBlobContainerClientProvider containerClientProvider) 19 | { 20 | _containerClientProvider = containerClientProvider; 21 | } 22 | 23 | /// 24 | public async Task GetBlob(string org, string blobPath, int? storageAccountNumber) 25 | { 26 | var containerClient = _containerClientProvider.GetBlobContainerClient(org, storageAccountNumber); 27 | var blobClient = containerClient.GetBlobClient(blobPath); 28 | Azure.Response response = await blobClient.DownloadAsync(); 29 | return response.Value.Content; 30 | } 31 | 32 | /// 33 | public async Task GetBlobProperties(string org, string blobPath, int? storageAccountNumber) 34 | { 35 | var containerClient = _containerClientProvider.GetBlobContainerClient(org, storageAccountNumber); 36 | var blobClient = containerClient.GetBlobClient(blobPath); 37 | Azure.Response response = await blobClient.GetPropertiesAsync(); 38 | return new BlobPropertyModel { LastModified = response.Value.LastModified }; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | paths: 9 | - 'src/**' 10 | - '.github/workflows/**' 11 | pull_request: 12 | branches: [main] 13 | types: [opened, synchronize, reopened] 14 | paths: 15 | - 'src/**' 16 | - '.github/workflows/**' 17 | schedule: 18 | - cron: '18 22 * * 3' 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-latest 24 | permissions: 25 | actions: read 26 | contents: read 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | include: 33 | - language: actions 34 | build-mode: none 35 | - language: csharp 36 | build-mode: autobuild 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 40 | - name: Setup .NET 9.0.* SDK 41 | if: matrix.language == 'csharp' 42 | uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 43 | with: 44 | dotnet-version: | 45 | 9.0.x 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 48 | with: 49 | languages: ${{ matrix.language }} 50 | build-mode: ${{ matrix.build-mode }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 55 | 56 | - name: Perform CodeQL Analysis 57 | uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 58 | with: 59 | category: '/language:${{matrix.language}}' 60 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/PlatformKeyVaultService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | using Altinn.Common.AccessToken.Configuration; 3 | using Altinn.FileScan.Services.Interfaces; 4 | 5 | using Azure; 6 | using Azure.Identity; 7 | using Azure.Security.KeyVault.Certificates; 8 | 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Altinn.FileScan.Services 12 | { 13 | /// 14 | /// Implementation of using default azure credentials to access the key vault defined in 15 | /// 16 | public class PlatformKeyVaultService : IPlatformKeyVault 17 | { 18 | private readonly string _vaultUri; 19 | 20 | /// 21 | /// Initializes a new instance of the class. 22 | /// 23 | public PlatformKeyVaultService(IOptions keyVaultSettings) 24 | { 25 | _vaultUri = keyVaultSettings.Value.SecretUri; 26 | } 27 | 28 | /// 29 | public async Task GetCertificateAsync(string certId) 30 | { 31 | CertificateClient certificateClient = new(new Uri(_vaultUri), new DefaultAzureCredential()); 32 | AsyncPageable certificatePropertiesPage = certificateClient.GetPropertiesOfCertificateVersionsAsync(certId); 33 | await foreach (CertificateProperties certificateProperties in certificatePropertiesPage) 34 | { 35 | if (certificateProperties.Enabled == true && 36 | (certificateProperties.ExpiresOn == null || certificateProperties.ExpiresOn >= DateTime.UtcNow)) 37 | { 38 | X509Certificate2 certificate = await certificateClient.DownloadCertificateAsync(certificateProperties.Name, certificateProperties.Version); 39 | return certificate; 40 | } 41 | } 42 | 43 | return null; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Clients/StorageClient.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | 4 | using Altinn.FileScan.Clients.Interfaces; 5 | using Altinn.FileScan.Configuration; 6 | using Altinn.FileScan.Exceptions; 7 | using Altinn.FileScan.Extensions; 8 | using Altinn.FileScan.Services.Interfaces; 9 | using Altinn.Platform.Storage.Interface.Models; 10 | 11 | using Microsoft.Extensions.Options; 12 | 13 | namespace Altinn.FileScan.Clients 14 | { 15 | /// 16 | /// Implementation of the 17 | /// 18 | public class StorageClient : IStorageClient 19 | { 20 | private readonly HttpClient _client; 21 | private readonly IAccessToken _accessTokenService; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | public StorageClient( 27 | HttpClient httpClient, 28 | IAccessToken accessToken, 29 | IOptions settings) 30 | { 31 | _accessTokenService = accessToken; 32 | 33 | _client = httpClient; 34 | _client.BaseAddress = new Uri(settings.Value.ApiStorageEndpoint); 35 | } 36 | 37 | /// 38 | public async Task PatchFileScanStatus(string instanceId, string dataElementId, FileScanStatus fileScanStatus) 39 | { 40 | string endpoint = $"instances/{instanceId}/dataelements/{dataElementId}/filescanstatus"; 41 | StringContent httpContent = new(JsonSerializer.Serialize(fileScanStatus), Encoding.UTF8, "application/json"); 42 | 43 | var accessToken = await _accessTokenService.Generate(); 44 | 45 | HttpResponseMessage response = await _client.PutAsync(endpoint, httpContent, accessToken); 46 | 47 | if (!response.IsSuccessStatusCode) 48 | { 49 | throw new PlatformHttpException(response, "Unexpected response from StorageClient when setting file scan status."); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Mocks/Authentication/ConfigurationManagerStub.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | using Microsoft.IdentityModel.Protocols; 8 | using Microsoft.IdentityModel.Protocols.OpenIdConnect; 9 | using Microsoft.IdentityModel.Tokens; 10 | 11 | namespace Altinn.FileScan.Tests.Mocks.Authentication 12 | { 13 | /// 14 | /// Represents a stub of to be used in integration tests. 15 | /// 16 | public class ConfigurationManagerStub : IConfigurationManager 17 | { 18 | /// 19 | /// Initializes a new instance of 20 | /// 21 | public ConfigurationManagerStub() 22 | { 23 | } 24 | 25 | /// 26 | public async Task GetConfigurationAsync(CancellationToken cancel) 27 | { 28 | ICollection signingKeys = await GetSigningKeys(); 29 | 30 | OpenIdConnectConfiguration configuration = new(); 31 | foreach (var securityKey in signingKeys) 32 | { 33 | configuration.SigningKeys.Add(securityKey); 34 | } 35 | 36 | return configuration; 37 | } 38 | 39 | /// 40 | public void RequestRefresh() 41 | { 42 | throw new NotImplementedException(); 43 | } 44 | 45 | private static async Task> GetSigningKeys() 46 | { 47 | List signingKeys = new(); 48 | 49 | X509Certificate2 cert = X509CertificateLoader.LoadCertificateFromFile("JWTValidationCert.cer"); 50 | 51 | SecurityKey key = new X509SecurityKey(cert); 52 | 53 | signingKeys.Add(key); 54 | 55 | return await Task.FromResult(signingKeys); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Functions.Tests/Altinn.FileScan.Functions.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | stylecop.json 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | PreserveNewest 44 | 45 | 46 | 47 | 48 | true 49 | $(NoWarn);1591 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Mocks/JwtTokenMock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IdentityModel.Tokens.Jwt; 3 | using System.Security.Claims; 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | using Microsoft.IdentityModel.Tokens; 7 | 8 | namespace Altinn.FileScan.Tests.Mocks 9 | { 10 | /// 11 | /// Represents a mechanism for creating JSON Web tokens for use in integration tests. 12 | /// 13 | public static class JwtTokenMock 14 | { 15 | /// 16 | /// Generates a token with a self signed certificate included in the integration test project. 17 | /// 18 | /// A new token. 19 | public static string GenerateToken(ClaimsPrincipal principal, TimeSpan tokenExipry, string issuer = "UnitTest") 20 | { 21 | JwtSecurityTokenHandler tokenHandler = new(); 22 | SecurityTokenDescriptor tokenDescriptor = new() 23 | { 24 | Subject = new ClaimsIdentity(principal.Identity), 25 | Expires = DateTime.UtcNow.AddSeconds(tokenExipry.TotalSeconds), 26 | SigningCredentials = GetSigningCredentials(issuer), 27 | Audience = "altinn.no", 28 | Issuer = issuer 29 | }; 30 | 31 | SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); 32 | string tokenstring = tokenHandler.WriteToken(token); 33 | 34 | return tokenstring; 35 | } 36 | 37 | private static SigningCredentials GetSigningCredentials(string issuer) 38 | { 39 | string certPath = "jwtselfsignedcert.pfx"; 40 | if (!issuer.Equals("UnitTest")) 41 | { 42 | certPath = $"{issuer}-org.pfx"; 43 | 44 | X509Certificate2 certIssuer = X509CertificateLoader.LoadPkcs12FromFile(certPath, (string)null); 45 | 46 | return new X509SigningCredentials(certIssuer, SecurityAlgorithms.RsaSha256); 47 | } 48 | 49 | X509Certificate2 cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, "qwer1234"); 50 | 51 | return new X509SigningCredentials(cert, SecurityAlgorithms.RsaSha256); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Clients/MuescheliClient.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | using Altinn.FileScan.Clients.Interfaces; 5 | using Altinn.FileScan.Configuration; 6 | using Altinn.FileScan.Exceptions; 7 | using Altinn.FileScan.Models; 8 | 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Altinn.FileScan.Clients 12 | { 13 | /// 14 | /// Implementation of the 15 | /// 16 | public class MuescheliClient : IMuescheliClient 17 | { 18 | private readonly HttpClient _client; 19 | private readonly JsonSerializerOptions _serializerOptions; 20 | 21 | /// 22 | /// Initializes a new instance of the class. 23 | /// 24 | public MuescheliClient( 25 | HttpClient httpClient, 26 | IOptions settings) 27 | { 28 | _client = httpClient; 29 | _client.BaseAddress = new Uri(settings.Value.ApiMuescheliEndpoint); 30 | 31 | _serializerOptions = new JsonSerializerOptions 32 | { 33 | PropertyNameCaseInsensitive = true, 34 | Converters = { new JsonStringEnumConverter() } 35 | }; 36 | } 37 | 38 | /// 39 | public async Task ScanStream(Stream stream, string filename) 40 | { 41 | string endpoint = $"scan"; 42 | 43 | using var content = new MultipartFormDataContent 44 | { 45 | { new StreamContent(stream), "file", filename } 46 | }; 47 | 48 | HttpResponseMessage response = await _client.PostAsync(endpoint, content); 49 | 50 | if (!response.IsSuccessStatusCode) 51 | { 52 | throw await MuescheliHttpException.CreateAsync(response.StatusCode, response); 53 | } 54 | 55 | var responseString = await response.Content.ReadAsStringAsync(); 56 | 57 | MuescheliResponse r = JsonSerializer.Deserialize>(responseString, _serializerOptions) 58 | .First(); 59 | 60 | return r.Result; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Altinn.FileScan.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | all 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | PreserveNewest 37 | 38 | 39 | PreserveNewest 40 | 41 | 42 | PreserveNewest 43 | 44 | 45 | PreserveNewest 46 | 47 | 48 | 49 | 50 | true 51 | $(NoWarn);1591 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /test/k6/src/api/storage.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import * as config from "../config.js"; 3 | 4 | /** 5 | * Api call to Storage:Instances to create an app instance and returns response 6 | * @param {*} altinnStudioRuntimeCookie token value to be sent in header for authentication 7 | * @param {*} partyId party id of the user to whom instance is to be created 8 | * @param {*} appOwner app owner name 9 | * @param {*} appName app name 10 | * @param {JSON} instanceJson instance json metadata sent in request body 11 | * @returns {JSON} Json object including response headers, body, timings 12 | */ 13 | 14 | const subskey = __ENV.subskey.toLowerCase(); 15 | 16 | export function postInstance(token, org, app, instanceJson) { 17 | var appId = org + "/" + app; 18 | var endpoint = config.platformStorage["instances"] + "?appId=" + appId; 19 | 20 | var params = { 21 | headers: { 22 | Authorization: `Bearer ${token}`, 23 | "Content-Type": "application/json", 24 | "Ocp-Apim-Subscription-Key": subskey, 25 | }, 26 | }; 27 | 28 | return http.post(endpoint, instanceJson, params); 29 | } 30 | 31 | export function hardDeleteInstance(token, instanceId) { 32 | var endpoint = 33 | config.platformStorage["instances"] + instanceId + "?hard=true"; 34 | 35 | var params = { 36 | headers: { 37 | Authorization: `Bearer ${token}`, 38 | "Ocp-Apim-Subscription-Key": subskey, 39 | }, 40 | }; 41 | 42 | 43 | return http.del(endpoint, undefined, params); 44 | } 45 | 46 | export function getInstance(token, instanceId) { 47 | var endpoint = config.platformStorage.instances + instanceId; 48 | 49 | var params = { 50 | headers: { 51 | Authorization: `Bearer ${token}`, 52 | }, 53 | }; 54 | 55 | return http.get(endpoint, params); 56 | } 57 | 58 | 59 | export function postData(token, instanceId, dataType, content){ 60 | var endpoint = config.platformStorage.instances + instanceId + "/data?dataType=" + dataType; 61 | 62 | var params = { 63 | headers: { 64 | Authorization: `Bearer ${token}`, 65 | "Content-Type": "application/octet-stream", 66 | "Content-Disposition": "attachment; filename=kattebilde.png", 67 | "Ocp-Apim-Subscription-Key": subskey, 68 | }, 69 | }; 70 | 71 | return http.post(endpoint, content, params); 72 | 73 | } -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Altinn.FileScan.Functions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | v4 5 | Exe 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | stylecop.json 34 | 35 | 36 | 37 | 38 | 39 | PreserveNewest 40 | 41 | 42 | PreserveNewest 43 | Never 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/AccessTokenService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | 3 | using Altinn.Common.AccessTokenClient.Configuration; 4 | using Altinn.Common.AccessTokenClient.Services; 5 | using Altinn.FileScan.Services.Interfaces; 6 | 7 | using Microsoft.Extensions.Caching.Memory; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Altinn.FileScan.Services 11 | { 12 | /// 13 | /// Implementation of the using key vault to retrieve sertificates and generating token 14 | /// 15 | public class AccessTokenService : IAccessToken 16 | { 17 | private readonly IPlatformKeyVault _keyVault; 18 | private readonly IAccessTokenGenerator _accessTokenGenerator; 19 | private readonly IMemoryCache _cache; 20 | private readonly MemoryCacheEntryOptions _cacheOptions; 21 | 22 | private const string CertId = "platform-access-token-private-cert"; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | public AccessTokenService(IPlatformKeyVault keyVault, IAccessTokenGenerator accessTokenGenerator, IMemoryCache cache, IOptions settings) 28 | { 29 | _keyVault = keyVault; 30 | _accessTokenGenerator = accessTokenGenerator; 31 | _cache = cache; 32 | 33 | _cacheOptions = new() 34 | { 35 | AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddSeconds(settings.Value.TokenLifetimeInSeconds - 2)) 36 | }; 37 | } 38 | 39 | /// 40 | /// Generates an access token with issuer `platform` and app `file-scan` 41 | /// 42 | public async Task Generate() 43 | { 44 | var accessTokenCacheKey = "accesstoken-platform-file-scan"; 45 | 46 | if (_cache.TryGetValue(accessTokenCacheKey, out string accessToken)) 47 | { 48 | return accessToken; 49 | } 50 | 51 | X509Certificate2 certificate = await _keyVault.GetCertificateAsync(CertId); 52 | 53 | accessToken = _accessTokenGenerator.GenerateAccessToken("platform", "file-scan", certificate); 54 | 55 | _cache.Set(accessTokenCacheKey, accessToken, _cacheOptions); 56 | 57 | return accessToken; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/k6/src/api/token-generator.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import { check } from "k6"; 3 | import encoding from "k6/encoding"; 4 | 5 | import * as config from "../config.js"; 6 | import { stopIterationOnFail } from "../errorhandler.js"; 7 | 8 | const tokenGeneratorUserName = __ENV.tokenGeneratorUserName; 9 | const tokenGeneratorUserPwd = __ENV.tokenGeneratorUserPwd; 10 | 11 | /* 12 | Generate enterprise token for test environment 13 | */ 14 | export function generateEnterpriseToken(queryParams) { 15 | const credentials = `${tokenGeneratorUserName}:${tokenGeneratorUserPwd}`; 16 | const encodedCredentials = encoding.b64encode(credentials); 17 | 18 | var endpoint = 19 | config.tokenGenerator.getEnterpriseToken + 20 | buildQueryParametersForEndpoint(queryParams); 21 | 22 | var params = { 23 | headers: { 24 | Authorization: `Basic ${encodedCredentials}`, 25 | }, 26 | }; 27 | 28 | var response = http.get(endpoint, params); 29 | 30 | if (response.status != 200) { 31 | stopIterationOnFail("Enterprise token generation failed", false, response); 32 | } 33 | 34 | var token = response.body; 35 | return token; 36 | } 37 | 38 | 39 | /* 40 | Generate personal token for test environment 41 | */ 42 | export function generatePersonalToken(queryParams) { 43 | const credentials = `${tokenGeneratorUserName}:${tokenGeneratorUserPwd}`; 44 | const encodedCredentials = encoding.b64encode(credentials); 45 | 46 | var endpoint = 47 | config.tokenGenerator.getPersonalToken + 48 | buildQueryParametersForEndpoint(queryParams); 49 | 50 | var params = { 51 | headers: { 52 | Authorization: `Basic ${encodedCredentials}`, 53 | }, 54 | }; 55 | 56 | var response = http.get(endpoint, params); 57 | 58 | if (response.status != 200) { 59 | stopIterationOnFail("Personal token generation failed", false, response); 60 | } 61 | 62 | var token = response.body; 63 | return token; 64 | } 65 | 66 | 67 | 68 | /* 69 | Build query parameters 70 | */ 71 | function buildQueryParametersForEndpoint(filterParameters) { 72 | var query = "?"; 73 | Object.keys(filterParameters).forEach(function (key) { 74 | if (Array.isArray(filterParameters[key])) { 75 | filterParameters[key].forEach((value) => { 76 | query += key + "=" + value + "&"; 77 | }); 78 | } else { 79 | query += key + "=" + filterParameters[key] + "&"; 80 | } 81 | }); 82 | query = query.slice(0, -1); 83 | 84 | return query; 85 | } 86 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Altinn.FileScan.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | all 32 | runtime; build; native; contentfiles; analyzers; buildtransitive 33 | 34 | 35 | stylecop.json 36 | 37 | 38 | 39 | 40 | 41 | <_Parameter1>$(MSBuildProjectName).Tests 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/Utils/PrincipalUtil.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Security.Claims; 4 | 5 | using Altinn.Common.AccessToken.Constants; 6 | using Altinn.FileScan.Tests.Mocks; 7 | 8 | using AltinnCore.Authentication.Constants; 9 | 10 | namespace Altinn.FileScan.Tests.Utils 11 | { 12 | public static class PrincipalUtil 13 | { 14 | public static ClaimsPrincipal GetClaimsPrincipal(int userId, int authenticationLevel, string scope = null) 15 | { 16 | string issuer = "www.altinn.no"; 17 | 18 | List claims = new() 19 | { 20 | new Claim(AltinnCoreClaimTypes.UserId, userId.ToString(), ClaimValueTypes.String, issuer), 21 | new Claim(AltinnCoreClaimTypes.UserName, "UserOne", ClaimValueTypes.String, issuer), 22 | new Claim(AltinnCoreClaimTypes.PartyID, userId.ToString(), ClaimValueTypes.Integer32, issuer), 23 | new Claim(AltinnCoreClaimTypes.AuthenticateMethod, "Mock", ClaimValueTypes.String, issuer), 24 | new Claim(AltinnCoreClaimTypes.AuthenticationLevel, authenticationLevel.ToString(), ClaimValueTypes.Integer32, issuer) 25 | }; 26 | 27 | if (scope != null) 28 | { 29 | claims.Add(new Claim("urn:altinn:scope", scope, ClaimValueTypes.String, "maskinporten")); 30 | } 31 | 32 | ClaimsIdentity identity = new("mock"); 33 | identity.AddClaims(claims); 34 | 35 | return new ClaimsPrincipal(identity); 36 | } 37 | 38 | public static string GetToken(int userId, int authenticationLevel = 2, string scope = null) 39 | { 40 | ClaimsPrincipal principal = GetClaimsPrincipal(userId, authenticationLevel, scope); 41 | 42 | string token = JwtTokenMock.GenerateToken(principal, new TimeSpan(0, 1, 5)); 43 | 44 | return token; 45 | } 46 | 47 | public static string GetAccessToken(string issuer, string app) 48 | { 49 | List claims = new() 50 | { 51 | new Claim(AccessTokenClaimTypes.App, app, ClaimValueTypes.String, issuer) 52 | }; 53 | 54 | ClaimsIdentity identity = new("mock"); 55 | identity.AddClaims(claims); 56 | ClaimsPrincipal principal = new(identity); 57 | string token = JwtTokenMock.GenerateToken(principal, new TimeSpan(0, 1, 5), issuer); 58 | 59 | return token; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Telemetry/RequestFilterProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.Extensions.Primitives; 3 | using OpenTelemetry; 4 | 5 | namespace Altinn.FileScan.Telemetry 6 | { 7 | /// 8 | /// Filter for requests (and child dependencies) that should not be logged. 9 | /// 10 | public class RequestFilterProcessor : BaseProcessor 11 | { 12 | private const string RequestKind = "Microsoft.AspNetCore.Hosting.HttpRequestIn"; 13 | private readonly IHttpContextAccessor _httpContextAccessor; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | public RequestFilterProcessor(IHttpContextAccessor httpContextAccessor = null) : base() 19 | { 20 | _httpContextAccessor = httpContextAccessor; 21 | } 22 | 23 | /// 24 | /// Determine whether to skip a request 25 | /// 26 | public override void OnStart(Activity activity) 27 | { 28 | bool skip = false; 29 | if (activity.OperationName == RequestKind) 30 | { 31 | skip = ExcludeRequest(_httpContextAccessor.HttpContext.Request.Path.Value); 32 | } 33 | else if (!(activity.Parent?.ActivityTraceFlags.HasFlag(ActivityTraceFlags.Recorded) ?? true)) 34 | { 35 | skip = true; 36 | } 37 | 38 | if (skip) 39 | { 40 | activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; 41 | } 42 | } 43 | 44 | /// 45 | /// No action on end 46 | /// 47 | /// xx 48 | public override void OnEnd(Activity activity) 49 | { 50 | if (activity.OperationName == RequestKind && _httpContextAccessor.HttpContext is not null && 51 | _httpContextAccessor.HttpContext.Request.Headers.TryGetValue("X-Forwarded-For", out StringValues ipAddress)) 52 | { 53 | activity.SetTag("ipAddress", ipAddress.FirstOrDefault()); 54 | } 55 | } 56 | 57 | private static bool ExcludeRequest(string localpath) 58 | { 59 | return localpath switch 60 | { 61 | var path when path.TrimEnd('/').EndsWith("/health", StringComparison.OrdinalIgnoreCase) => true, 62 | _ => false 63 | }; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/k6/src/report.js: -------------------------------------------------------------------------------- 1 | var replacements = { 2 | '&': '&', 3 | '<': '<', 4 | '>': '>', 5 | "'": ''', 6 | '"': '"', 7 | }; 8 | 9 | function escapeHTML(str) { 10 | return str.replace(/[&<>'"]/g, function (char) { 11 | return replacements[char]; 12 | }); 13 | } 14 | 15 | function checksToTestcase(checks, failures) { 16 | var testCases = []; 17 | if (checks.length > 0) { 18 | checks.forEach((check) => { 19 | if (check.passes >= 1 && check.fails === 0) { 20 | testCases.push(``); 21 | } else { 22 | failures++; 23 | testCases.push(``); 24 | } 25 | }); 26 | } 27 | return [testCases, failures]; 28 | } 29 | 30 | /** 31 | * Generate a junit xml string from the summary of a k6 run considering each checks as a test case 32 | * @param {*} data 33 | * @param {String} suiteName Name of the test ex., filename 34 | * @returns junit xml string 35 | */ 36 | export function generateJUnitXML(data, suiteName) { 37 | var failures = 0; 38 | var allTests = [], 39 | testSubset = []; 40 | var time = data.state.testRunDurationMs ? data.state.testRunDurationMs : 0; 41 | 42 | if (data.root_group.hasOwnProperty('groups') && data.root_group.groups.length > 0) { 43 | var groups = data.root_group.groups; 44 | groups.forEach((group) => { 45 | var testSubset = []; 46 | if (group.hasOwnProperty('checks')) [testSubset, failures] = checksToTestcase(group.checks, failures); 47 | allTests.push(...testSubset); 48 | }); 49 | } 50 | 51 | if (data.root_group.hasOwnProperty('checks')) [testSubset, failures] = checksToTestcase(data.root_group.checks, failures); 52 | allTests.push(...testSubset); 53 | 54 | return ( 55 | `\n\n` + 57 | `\n` + 59 | `${allTests.join('\n')}\n\n` 60 | ); 61 | } 62 | 63 | /** 64 | * Returns string that is path to the reports based on the OS where the test in run 65 | * @param {String} reportName name of the file with extension 66 | * @returns path 67 | */ 68 | export function reportPath(reportName) { 69 | var path = `src/reports/${reportName}`; 70 | if (!(__ENV.OS || __ENV.AGENT_OS)) path = `/${path}`; 71 | return path; 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Altinn FileScan 2 | 3 | Microservice that performs asynchronous malware scanning of files uploaded to Altinn 3. 4 | Documentation for setting up filescan in an app: https://docs.altinn.studio/app/development/configuration/filescan/ 5 | 6 | ## Build status 7 | [![Filescan build status](https://dev.azure.com/brreg/altinn-studio/_apis/build/status/altinn-platform/filescan-master?label=altinn/filescan)](https://dev.azure.com/brreg/altinn-studio/_build/latest?definitionId=405) 8 | 9 | ## Getting Started 10 | 11 | These instructions will get you a copy of the filescan component up and running on your machine for development and testing purposes. 12 | 13 | ### Prerequisites 14 | 15 | 1. [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) 16 | 2. Newest [Git](https://git-scm.com/downloads) 17 | 3. A code editor - we like [Visual Studio Code](https://code.visualstudio.com/download) 18 | - Install [Azure Functions extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions). You can also install the [Azure Tools extension pack](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-node-azure-pack), which is recommended for working with Azure resources. 19 | - Also install [recommended extensions](https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions) (e.g. [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp)) 20 | 5. [Podman](https://podman.io/) or another container tool such as Docker Desktop 21 | 6. Install [Azurite](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage#install-azurite) 22 | 7. Install [Azure Functions Core Tool](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Cisolated-process%2Cnode-v4%2Cpython-v2%2Chttp-trigger%2Ccontainer-apps&pivots=programming-language-powershell#install-the-azure-functions-core-tools) 23 | 8. Follow the readme to run [Muescheli](https://github.com/Altinn/muescheli) locally 24 | 25 | ### Cloning the application 26 | Clone Altinn FileScan repo and navigate to the folder. 27 | 28 | 29 | ```bash 30 | git clone https://github.com/Altinn/altinn-file-scan 31 | cd altinn-file-scan 32 | ``` 33 | 34 | ### Running the application 35 | 36 | Start Altinn FileScan application 37 | ```bash 38 | cd src/Altinn.FileScan 39 | dotnet run 40 | ``` 41 | 42 | The filescan solution is now available locally at http://localhost:5200/. 43 | To access swagger use http://localhost:5200/filescan/swagger. 44 | 45 | ### Running functions 46 | 47 | - [Start Azurite](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage#run-azurite) 48 | 49 | Start Altinn FileScan Functions 50 | ```bash 51 | cd src/Altinn.FileScan.Functions 52 | func start 53 | ``` -------------------------------------------------------------------------------- /test/k6/src/setup.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import { check } from "k6"; 3 | import * as tokenGenerator from "./api/token-generator.js"; 4 | import * as config from "./config.js"; 5 | import { stopIterationOnFail } from "./errorhandler.js"; 6 | 7 | const environment = __ENV.env.toLowerCase(); 8 | 9 | /* 10 | * generate an altinn token for TTD based on the environment using AltinnTestTools 11 | * @returns altinn token with the provided scopes for an org/appowner 12 | */ 13 | export function getAltinnTokenForTTD(scopes) { 14 | var queryParams = { 15 | env: environment, 16 | scopes: scopes, 17 | org: "ttd", 18 | orgNo: "991825827", 19 | }; 20 | 21 | return tokenGenerator.generateEnterpriseToken(queryParams); 22 | } 23 | 24 | export function getPersonalTokenForTest(userId, partyid, pid) { 25 | var queryParams = { 26 | env: environment, 27 | scopes: "altinn:enduser", 28 | userId: userId, 29 | partyId: partyid, 30 | pid: pid, 31 | }; 32 | 33 | return tokenGenerator.generatePersonalToken(queryParams); 34 | } 35 | 36 | export function getPersonalTokenForProd(username, password) { 37 | let aspxauth = getAspxAuth(username, password); 38 | return getAltinnStudioRuntimeToken(aspxauth); 39 | } 40 | 41 | function getAspxAuth(username, password) { 42 | var endpoint = config.sbl.authenticationWithPassword; 43 | 44 | var requestBody = { 45 | UserName: username, 46 | UserPassword: password, 47 | }; 48 | 49 | var params = { 50 | headers: { 51 | Accept: "application/hal+json", 52 | }, 53 | }; 54 | 55 | var res = http.post(endpoint, requestBody, params); 56 | var success = check(res, { 57 | "Authentication towards Altinn 2 Success": (r) => r.status === 200, 58 | }); 59 | 60 | stopIterationOnFail("Authentication towards Altinn 2 Failed", success, res); 61 | 62 | const cookieName = config.authCookieName; 63 | var cookieValue = res.cookies[cookieName][0].value; 64 | return cookieValue; 65 | } 66 | 67 | export function getAltinnStudioRuntimeToken(aspxauthCookie) { 68 | clearCookies(); 69 | var endpoint = config.platformAuthentication.authentication + '?goto=' + config.platformAuthentication.refresh; 70 | 71 | var params = { 72 | cookies: { [config.authCookieName]: aspxauthCookie }, 73 | }; 74 | 75 | var res = http.get(endpoint, params); 76 | var success = check(res, { 77 | 'T3.0 Authentication Success': (r) => r.status === 200, 78 | }); 79 | stopIterationOnFail('T3.0 Authentication Failed', success, res); 80 | return res.body; 81 | } 82 | 83 | //Function to clear the cookies under baseurl by setting the expires field to a past date 84 | export function clearCookies() { 85 | var jar = http.cookieJar(); 86 | jar.set('https://' + config.baseUrl, 'AltinnStudioRuntime', 'test', { expires: 'Mon, 02 Jan 2010 15:04:05 MST' }); 87 | jar.set('https://' + config.baseUrl, config.authCookieName, 'test', { expires: 'Mon, 02 Jan 2010 15:04:05 MST' }); 88 | } -------------------------------------------------------------------------------- /.github/workflows/build-and-analyze.yml: -------------------------------------------------------------------------------- 1 | name: Code test and analysis 2 | permissions: 3 | contents: read 4 | checks: write 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | types: [opened, synchronize, reopened] 12 | workflow_dispatch: 13 | jobs: 14 | build-test-analyze: 15 | name: Build, test & analyze 16 | if: ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || github.event_name == 'push') && github.repository_owner == 'Altinn' 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 21 | with: 22 | dotnet-version: | 23 | 9.0.x 24 | - name: Set up Java 25 | uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 26 | with: 27 | distribution: 'temurin' 28 | java-version: 17 29 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 30 | with: 31 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 32 | - name: Install SonarCloud scanners 33 | run: | 34 | dotnet tool install --global dotnet-sonarscanner 35 | - name: Set inotify instances 36 | run: echo fs.inotify.max_user_instances=8192 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p 37 | - name: Build & Test 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 40 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 41 | run: | 42 | dotnet-sonarscanner begin /k:"Altinn_altinn-file-scan" /o:"altinn" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vstest.reportsPaths="**/*.trx" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" 43 | 44 | dotnet build Altinn.FileScan.sln -v q 45 | 46 | dotnet test Altinn.FileScan.sln \ 47 | -v q \ 48 | --collect:"XPlat Code Coverage" \ 49 | --results-directory TestResults/ \ 50 | --logger "trx;" \ 51 | --configuration release \ 52 | -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover 53 | - name: Complete sonar analysis 54 | if: always() 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 57 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 58 | run: | 59 | dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" 60 | - name: Process .NET test result 61 | if: always() 62 | uses: NasAmin/trx-parser@359b39f5319df4478443ef1f0eb952f159896995 # v0.7.0 63 | with: 64 | TRX_PATH: ${{ github.workspace }}/TestResults 65 | REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Services/CertificateResolverService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography.X509Certificates; 3 | using System.Threading.Tasks; 4 | 5 | using Altinn.FileScan.Functions.Configuration; 6 | using Altinn.FileScan.Functions.Services.Interfaces; 7 | 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace Altinn.FileScan.Functions.Services 12 | { 13 | /// 14 | /// Class to resolve certificate to generate an access token 15 | /// 16 | public class CertificateResolverService : ICertificateResolverService 17 | { 18 | private readonly ILogger _logger; 19 | private readonly CertificateResolverSettings _certificateResolverSettings; 20 | private readonly IKeyVaultService _keyVaultService; 21 | private readonly KeyVaultSettings _keyVaultSettings; 22 | private DateTime _reloadTime; 23 | private X509Certificate2 _cachedX509Certificate = null; 24 | private readonly object _lockObject = new object(); 25 | 26 | /// 27 | /// Default constructor 28 | /// 29 | /// The logger 30 | /// Settings for certificate resolver 31 | /// Key vault service 32 | /// Key vault settings 33 | public CertificateResolverService( 34 | ILogger logger, 35 | IOptions certificateResolverSettings, 36 | IKeyVaultService keyVaultService, 37 | IOptions keyVaultSettings) 38 | { 39 | _logger = logger; 40 | _certificateResolverSettings = certificateResolverSettings.Value; 41 | _keyVaultService = keyVaultService; 42 | _keyVaultSettings = keyVaultSettings.Value; 43 | _reloadTime = DateTime.MinValue; 44 | } 45 | 46 | /// 47 | /// Find the configured 48 | /// 49 | /// 50 | public async Task GetCertificateAsync() 51 | { 52 | if (DateTime.UtcNow > _reloadTime || _cachedX509Certificate == null) 53 | { 54 | var certificate = await _keyVaultService.GetCertificateAsync( 55 | _keyVaultSettings.KeyVaultURI, 56 | _keyVaultSettings.PlatformCertSecretId); 57 | lock (_lockObject) 58 | { 59 | _cachedX509Certificate = certificate; 60 | 61 | _reloadTime = DateTime.UtcNow.AddSeconds(_certificateResolverSettings.CacheCertLifetimeInSeconds); 62 | _logger.LogInformation("Certificate reloaded."); 63 | } 64 | } 65 | 66 | return _cachedX509Certificate; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Altinn.FileScan.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.4.33213.308 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.FileScan", "src\Altinn.FileScan\Altinn.FileScan.csproj", "{E45FF789-BC20-4AB0-94CE-5FE97DF537EA}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.FileScan.Functions", "src\Altinn.FileScan.Functions\Altinn.FileScan.Functions.csproj", "{9F48F5B0-DD49-410F-A183-28E1C73CBF9E}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.FileScan.Tests", "test\Altinn.FileScan.Tests\Altinn.FileScan.Tests.csproj", "{7A1B7A7B-34E7-493E-AD70-12F6BA1C2967}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution items", "{A2638AF4-5B8C-4F84-8C70-393C726772FC}" 13 | ProjectSection(SolutionItems) = preProject 14 | Dockerfile = Dockerfile 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.FileScan.Functions.Tests", "test\Altinn.FileScan.Functions.Tests\Altinn.FileScan.Functions.Tests.csproj", "{015AEB5F-3AAB-4F35-AB7D-48E4A2985C70}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {E45FF789-BC20-4AB0-94CE-5FE97DF537EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {E45FF789-BC20-4AB0-94CE-5FE97DF537EA}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {E45FF789-BC20-4AB0-94CE-5FE97DF537EA}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {E45FF789-BC20-4AB0-94CE-5FE97DF537EA}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {9F48F5B0-DD49-410F-A183-28E1C73CBF9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {9F48F5B0-DD49-410F-A183-28E1C73CBF9E}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {9F48F5B0-DD49-410F-A183-28E1C73CBF9E}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {9F48F5B0-DD49-410F-A183-28E1C73CBF9E}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {7A1B7A7B-34E7-493E-AD70-12F6BA1C2967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {7A1B7A7B-34E7-493E-AD70-12F6BA1C2967}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {7A1B7A7B-34E7-493E-AD70-12F6BA1C2967}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {7A1B7A7B-34E7-493E-AD70-12F6BA1C2967}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {015AEB5F-3AAB-4F35-AB7D-48E4A2985C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {015AEB5F-3AAB-4F35-AB7D-48E4A2985C70}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {015AEB5F-3AAB-4F35-AB7D-48E4A2985C70}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {015AEB5F-3AAB-4F35-AB7D-48E4A2985C70}.Release|Any CPU.Build.0 = Release|Any CPU 42 | EndGlobalSection 43 | GlobalSection(SolutionProperties) = preSolution 44 | HideSolutionNode = FALSE 45 | EndGlobalSection 46 | GlobalSection(ExtensibilityGlobals) = postSolution 47 | SolutionGuid = {BE080BB6-1809-41C8-A334-89B8B45FAB35} 48 | EndGlobalSection 49 | EndGlobal 50 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Functions.Tests/TestingServices/CertificateResolverServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography.X509Certificates; 2 | 3 | using Altinn.FileScan.Functions.Configuration; 4 | using Altinn.FileScan.Functions.Services; 5 | using Altinn.FileScan.Functions.Services.Interfaces; 6 | 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | using Moq; 11 | using Xunit; 12 | 13 | namespace Altinn.FileScan.Tests 14 | { 15 | public class CertificateResolverServiceTests 16 | { 17 | private readonly Mock> _mockLogger = new Mock>(); 18 | private readonly IOptions _certificateResolverSettings = Options.Create(new CertificateResolverSettings { CacheCertLifetimeInSeconds = 1 }); 19 | private readonly Mock _mockKeyVaultService = new Mock(); 20 | private readonly IOptions _keyVaultSettings = Options.Create(new KeyVaultSettings()); 21 | 22 | [Fact] 23 | public async Task GetCertificateAsync_ReturnsCachedCertificate_WhenCalledMultipleTimesWithinCacheLifetime() 24 | { 25 | // Arrange 26 | string certPath = "platform-org.pfx"; 27 | X509Certificate2 cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, null); 28 | 29 | _mockKeyVaultService.Setup(s => s.GetCertificateAsync(It.IsAny(), It.IsAny())) 30 | .ReturnsAsync(cert) 31 | .Verifiable(); 32 | 33 | var resolverService = new CertificateResolverService(_mockLogger.Object, _certificateResolverSettings, _mockKeyVaultService.Object, _keyVaultSettings); 34 | 35 | // Act 36 | await resolverService.GetCertificateAsync(); 37 | await resolverService.GetCertificateAsync(); 38 | 39 | // Assert 40 | _mockKeyVaultService.Verify(s => s.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Once); 41 | } 42 | 43 | [Fact] 44 | public async Task GetCertificateAsync_ReloadsCertificate_WhenCalledAfterCacheLifetime() 45 | { 46 | // Arrange 47 | string certPath = "platform-org.pfx"; 48 | X509Certificate2 cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, null); 49 | 50 | _mockKeyVaultService.Setup(s => s.GetCertificateAsync(It.IsAny(), It.IsAny())) 51 | .ReturnsAsync(cert) 52 | .Verifiable(); 53 | 54 | var resolverService = new CertificateResolverService(_mockLogger.Object, _certificateResolverSettings, _mockKeyVaultService.Object, _keyVaultSettings); 55 | 56 | // Act 57 | await resolverService.GetCertificateAsync(); 58 | await Task.Delay(2000); // Wait for longer than the cache lifetime 59 | await resolverService.GetCertificateAsync(); 60 | 61 | // Assert 62 | _mockKeyVaultService.Verify(s => s.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Altinn.FileScan.Functions/Clients/FileScanClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Text; 5 | using System.Text.Json.Nodes; 6 | using System.Threading.Tasks; 7 | 8 | using Altinn.Common.AccessTokenClient.Services; 9 | using Altinn.FileScan.Functions.Clients.Interfaces; 10 | using Altinn.FileScan.Functions.Configuration; 11 | using Altinn.FileScan.Functions.Extensions; 12 | using Altinn.FileScan.Functions.Services.Interfaces; 13 | 14 | using Microsoft.Extensions.Logging; 15 | using Microsoft.Extensions.Options; 16 | 17 | namespace Altinn.FileScan.Functions.Clients 18 | { 19 | /// 20 | public class FileScanClient : IFileScanClient 21 | { 22 | private readonly HttpClient _client; 23 | private readonly IAccessTokenGenerator _accessTokenGenerator; 24 | private readonly ICertificateResolverService _certificateResolverService; 25 | private readonly ILogger _logger; 26 | 27 | /// 28 | /// Initializes a new instance of the class. 29 | /// 30 | public FileScanClient( 31 | HttpClient httpClient, 32 | IAccessTokenGenerator accessTokenGenerator, 33 | ICertificateResolverService certificateResolverService, 34 | IOptions platformSettings, 35 | ILogger logger) 36 | { 37 | _accessTokenGenerator = accessTokenGenerator; 38 | _certificateResolverService = certificateResolverService; 39 | _logger = logger; 40 | 41 | _client = httpClient; 42 | _client.BaseAddress = new Uri(platformSettings.Value.ApiFileScanEndpoint); 43 | } 44 | 45 | /// 46 | public async Task PostDataElementScanRequest(string dataElementScanRequest) 47 | { 48 | StringContent httpContent = new(dataElementScanRequest, Encoding.UTF8, "application/json"); 49 | 50 | string endpointUrl = "dataelement"; 51 | 52 | var accessToken = await GenerateAccessToken(); 53 | 54 | HttpResponseMessage response = await _client.PostAsync(endpointUrl, httpContent, accessToken); 55 | if (!response.IsSuccessStatusCode) 56 | { 57 | var n = JsonNode.Parse(dataElementScanRequest, new() { PropertyNameCaseInsensitive = true }); 58 | string dataElementId = n["dataElementId"].ToString(); 59 | var msg = $"// Post to FileScan for id {dataElementId} failed with status code {response.StatusCode}"; 60 | _logger.LogError("{msg}", msg); 61 | throw new HttpRequestException(msg); 62 | } 63 | } 64 | 65 | /// 66 | /// Generate a fresh access token using the client certificate 67 | /// 68 | protected async Task GenerateAccessToken() 69 | { 70 | X509Certificate2 certificate = await _certificateResolverService.GetCertificateAsync(); 71 | 72 | string accessToken = _accessTokenGenerator.GenerateAccessToken("platform", "file-scan", certificate); 73 | return accessToken; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/use-case-ATX.yaml: -------------------------------------------------------------------------------- 1 | name: Use Case - AT 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '*/15 * * * *' 9 | 10 | jobs: 11 | AT22: 12 | environment: AT22 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 16 | - name: Run use case tests 17 | run: | 18 | cd test/k6 19 | docker compose run k6 run /src/tests/end2end.js -e subskey=${{ secrets.APIM_SUBSKEY }} -e env=${{ vars.ENV }} -e org=${{ vars.ORG }} -e app=${{ vars.APP }} -e userId=${{ secrets.USER_ID }} -e personNumber=${{ secrets.PERSON_NUMBER }} -e tokenGeneratorUserName=${{ secrets.TOKENGENERATOR_USERNAME }} -e tokenGeneratorUserPwd=${{ secrets.TOKENGENERATOR_USERPASSWORD }} -e partyId=${{ secrets.PARTY_ID }} 20 | 21 | AT23: 22 | environment: AT23 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 26 | - name: Run use case tests 27 | run: | 28 | cd test/k6 29 | docker compose run k6 run /src/tests/end2end.js -e subskey=${{ secrets.APIM_SUBSKEY }} -e env=${{ vars.ENV }} -e org=${{ vars.ORG }} -e app=${{ vars.APP }} -e userId=${{ secrets.USER_ID }} -e personNumber=${{ secrets.PERSON_NUMBER }} -e tokenGeneratorUserName=${{ secrets.TOKENGENERATOR_USERNAME }} -e tokenGeneratorUserPwd=${{ secrets.TOKENGENERATOR_USERPASSWORD }} -e partyId=${{ secrets.PARTY_ID }} 30 | 31 | AT24: 32 | environment: AT24 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 36 | - name: Run use case tests 37 | run: | 38 | cd test/k6 39 | docker compose run k6 run /src/tests/end2end.js -e subskey=${{ secrets.APIM_SUBSKEY }} -e env=${{ vars.ENV }} -e org=${{ vars.ORG }} -e app=${{ vars.APP }} -e userId=${{ secrets.USER_ID }} -e personNumber=${{ secrets.PERSON_NUMBER }} -e tokenGeneratorUserName=${{ secrets.TOKENGENERATOR_USERNAME }} -e tokenGeneratorUserPwd=${{ secrets.TOKENGENERATOR_USERPASSWORD }} -e partyId=${{ secrets.PARTY_ID }} 40 | 41 | report-status: 42 | name: Report status 43 | runs-on: ubuntu-latest 44 | needs: [AT22, AT23, AT24] 45 | if: always() && contains(join(needs.*.result, ','), 'failure') 46 | steps: 47 | - name: Build failure report 48 | run: | 49 | report=":warning: FileScan use case test failure in AT :warning: \n See environment(s) listed below: \n" 50 | 51 | if [ ${{ needs.AT22.result }} = 'failure' ]; then 52 | report+="AT22 \r\n" 53 | fi 54 | 55 | if [ ${{ needs.AT23.result }} = 'failure' ]; then 56 | report+="AT23 \r\n" 57 | fi 58 | 59 | if [ ${{ needs.AT24.result }} = 'failure' ]; then 60 | report+="AT24 \r\n" 61 | fi 62 | 63 | report+="\n Workflow available here: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" 64 | echo "stepreport="$report >> $GITHUB_ENV 65 | 66 | - name: Report failure to Slack 67 | id: slack 68 | uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 69 | with: 70 | webhook-type: incoming-webhook 71 | webhook: ${{ secrets.SLACK_WEBHOOK_URL_TEST }} 72 | payload: | 73 | { 74 | "text": "${{ env.stepreport }}" 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Services/DataElementService.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Clients.Interfaces; 2 | using Altinn.FileScan.Exceptions; 3 | using Altinn.FileScan.Models; 4 | using Altinn.FileScan.Repository.Interfaces; 5 | using Altinn.FileScan.Services.Interfaces; 6 | using Altinn.Platform.Storage.Interface.Enums; 7 | using Altinn.Platform.Storage.Interface.Models; 8 | 9 | namespace Altinn.FileScan.Services 10 | { 11 | /// 12 | /// Implementation of the IDataElement service integrating with Blob Storage and ClamAV complete the scan of a data element. 13 | /// 14 | public class DataElementService : IDataElement 15 | { 16 | private readonly IAppOwnerBlob _repository; 17 | private readonly IStorageClient _storageClient; 18 | private readonly IMuescheliClient _muescheliClient; 19 | private readonly ILogger _logger; 20 | 21 | /// 22 | /// Initializes a new instance of the class. 23 | /// 24 | public DataElementService(IAppOwnerBlob repository, IMuescheliClient muescheliClient, IStorageClient storageClient, ILogger logger) 25 | { 26 | _repository = repository; 27 | _storageClient = storageClient; 28 | _muescheliClient = muescheliClient; 29 | _logger = logger; 30 | } 31 | 32 | /// 33 | public async Task Scan(DataElementScanRequest scanRequest) 34 | { 35 | try 36 | { 37 | var blobProps = await _repository.GetBlobProperties(scanRequest.Org, scanRequest.BlobStoragePath, scanRequest.StorageAccountNumber); 38 | 39 | if (blobProps.LastModified != scanRequest.Timestamp) 40 | { 41 | // we replace newline characters in log messages to avoid log injection attacks 42 | _logger.LogError( 43 | "Scan request timestamp != blob last modified timestamp, scan request aborted. Instance Id: {InstanceId}, DataElementId: {DataElementId}, timestamp diff: {TimeDiff} seconds", 44 | scanRequest.InstanceId.Replace(Environment.NewLine, string.Empty), 45 | scanRequest.DataElementId.Replace(Environment.NewLine, string.Empty), 46 | scanRequest.Timestamp.Subtract(blobProps.LastModified).TotalSeconds); 47 | return; 48 | } 49 | 50 | var stream = await _repository.GetBlob(scanRequest.Org, scanRequest.BlobStoragePath, scanRequest.StorageAccountNumber); 51 | 52 | var filename = $"{scanRequest.DataElementId}.bin"; 53 | ScanResult scanResult = await _muescheliClient.ScanStream(stream, filename); 54 | 55 | FileScanResult fileScanResult = FileScanResult.Pending; 56 | 57 | switch (scanResult) 58 | { 59 | case ScanResult.OK: 60 | fileScanResult = FileScanResult.Clean; 61 | break; 62 | case ScanResult.FOUND: 63 | fileScanResult = FileScanResult.Infected; 64 | break; 65 | case ScanResult.ERROR: 66 | case ScanResult.PARSE_ERROR: 67 | case ScanResult.UNDEFINED: 68 | _logger.LogError("Scan of {DataElementId} completed with unexpected result {ScanResult}.", scanRequest.DataElementId.Replace(Environment.NewLine, string.Empty), scanResult); 69 | throw MuescheliScanResultException.Create(scanRequest.DataElementId, scanResult); 70 | } 71 | 72 | FileScanStatus status = new() 73 | { 74 | ContentHash = string.Empty, 75 | FileScanResult = fileScanResult 76 | }; 77 | 78 | await _storageClient.PatchFileScanStatus(scanRequest.InstanceId, scanRequest.DataElementId, status); 79 | } 80 | catch (MuescheliHttpException e) 81 | { 82 | _logger.LogError(e, "Scan of {DataElementId} failed with an http exception.", scanRequest.DataElementId.Replace(Environment.NewLine, string.Empty)); 83 | throw; 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Repository/BlobContainerClientProvider.cs: -------------------------------------------------------------------------------- 1 | using Altinn.FileScan.Configuration; 2 | using Altinn.FileScan.Repository.Interfaces; 3 | using Azure.Core; 4 | using Azure.Identity; 5 | using Azure.Storage; 6 | using Azure.Storage.Blobs; 7 | using Microsoft.Extensions.Caching.Memory; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Altinn.FileScan.Repository 11 | { 12 | /// 13 | /// Represents a collection of Azure Blob Container Clients and the means to obtain new tokens when needed. 14 | /// This class should be used as a singleton through dependency injection. 15 | /// 16 | public class BlobContainerClientProvider : IBlobContainerClientProvider 17 | { 18 | private const string _credsCacheKey = "creds"; 19 | 20 | private readonly AppOwnerAzureStorageConfig _storageConfig; 21 | private readonly ILogger _logger; 22 | 23 | private readonly IMemoryCache _memoryCache; 24 | 25 | private static readonly MemoryCacheEntryOptions _cacheEntryOptionsCreds = new MemoryCacheEntryOptions() 26 | .SetPriority(CacheItemPriority.High) 27 | .SetAbsoluteExpiration(new TimeSpan(10, 0, 0)); 28 | 29 | private static readonly MemoryCacheEntryOptions _cacheEntryOptionsBlobClient = new MemoryCacheEntryOptions() 30 | .SetPriority(CacheItemPriority.High) 31 | .SetAbsoluteExpiration(new TimeSpan(10, 0, 0)); 32 | 33 | /// 34 | /// Initializes a new instance of the class/>. 35 | /// 36 | public BlobContainerClientProvider( 37 | IOptions storageConfiguration, 38 | ILogger logger, 39 | IMemoryCache memoryCache) 40 | { 41 | _storageConfig = storageConfiguration.Value; 42 | _logger = logger; 43 | _memoryCache = memoryCache; 44 | } 45 | 46 | /// 47 | public BlobContainerClient GetBlobContainerClient(string org, int? storageAccountNumber) 48 | { 49 | if (!_storageConfig.OrgStorageAccount.Equals("devstoreaccount1")) 50 | { 51 | string cacheKey = GetClientCacheKey(org, storageAccountNumber); 52 | if (!_memoryCache.TryGetValue(cacheKey, out BlobContainerClient client)) 53 | { 54 | string containerName = string.Format(_storageConfig.OrgStorageContainer, org); 55 | string accountName = string.Format(_storageConfig.OrgStorageAccount, org); 56 | if (storageAccountNumber != null) 57 | { 58 | accountName = accountName.Substring(0, accountName.Length - 2) + ((int)storageAccountNumber).ToString("D2"); 59 | } 60 | 61 | UriBuilder fullUri = new() 62 | { 63 | Scheme = "https", 64 | Host = $"{accountName}.blob.core.windows.net", 65 | Path = $"{containerName}" 66 | }; 67 | 68 | client = new BlobContainerClient(fullUri.Uri, GetCachedCredentials()); 69 | _memoryCache.Set(cacheKey, client, _cacheEntryOptionsBlobClient); 70 | } 71 | 72 | return client; 73 | } 74 | 75 | StorageSharedKeyCredential storageCredentials = new(_storageConfig.AccountName, _storageConfig.AccountKey); 76 | Uri storageUrl = new(_storageConfig.BlobEndPoint); 77 | BlobServiceClient commonBlobClient = new(storageUrl, storageCredentials); 78 | return commonBlobClient.GetBlobContainerClient(_storageConfig.StorageContainer); 79 | } 80 | 81 | private TokenCredential GetCachedCredentials() 82 | { 83 | if (!_memoryCache.TryGetValue(_credsCacheKey, out DefaultAzureCredential creds)) 84 | { 85 | creds = new(); 86 | _memoryCache.Set(_credsCacheKey, creds, _cacheEntryOptionsCreds); 87 | } 88 | 89 | return creds; 90 | } 91 | 92 | private static string GetClientCacheKey(string org, int? storageAccountNumber) 93 | { 94 | return $"blob-{org}-{storageAccountNumber}"; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /test/k6/src/tests/end2end.js: -------------------------------------------------------------------------------- 1 | /* 2 | Test script to platform events api with user token 3 | Command: docker compose run k6 run /src/tests/end2end.js -e tokenGeneratorUserName=autotest -e tokenGeneratorUserPwd=*** -e env=*** -e subskey=*** -e partyId=*** -e personNumber=*** -e org=ttd -e app=filescan-end-to-end 4 | */ 5 | import { check, sleep } from "k6"; 6 | import * as setupToken from "../setup.js"; 7 | import { generateJUnitXML, reportPath } from "../report.js"; 8 | import * as storageApi from "../api/storage.js"; 9 | import { addErrorCount, stopIterationOnFail } from "../errorhandler.js"; 10 | 11 | const instanceJson = JSON.parse(open("../data/instance.json")); 12 | const kattebilde = open("../data/kattebilde.png"); 13 | 14 | export const options = { 15 | thresholds: { 16 | errors: ['count<1'], 17 | }, 18 | }; 19 | 20 | export function setup() { 21 | const partyId = __ENV.partyId; 22 | const personNumber = __ENV.personNumber; 23 | const userId = __ENV.userId; 24 | const username = __ENV.username; 25 | const password = __ENV.password; 26 | const environment = __ENV.env; 27 | const org = __ENV.org.toLowerCase(); 28 | const app = __ENV.app.toLowerCase(); 29 | 30 | let userToken; 31 | 32 | if (environment === "prod"){ 33 | userToken = setupToken.getPersonalTokenForProd(username, password); 34 | }else{ 35 | userToken = setupToken.getPersonalTokenForTest( 36 | userId, 37 | partyId, 38 | personNumber 39 | ); 40 | } 41 | 42 | var instanceTemplate = instanceJson; 43 | instanceTemplate.instanceOwner = { 44 | partyId: partyId, 45 | personNumber: personNumber, 46 | }; 47 | instanceTemplate.org = org; 48 | instanceTemplate.appId = org + "/" + app; 49 | 50 | var data = { 51 | token: userToken, 52 | instance: instanceTemplate, 53 | org: org, 54 | app: app, 55 | kattebilde: kattebilde, 56 | }; 57 | 58 | return data; 59 | } 60 | 61 | export default function (data) { 62 | var res, success; 63 | 64 | res = storageApi.postInstance( 65 | data.token, 66 | data.org, 67 | data.app, 68 | JSON.stringify(data.instance) 69 | ); 70 | 71 | success = check(res, { 72 | "POST instance status is 201.": (r) => 73 | r.status === 201, 74 | }); 75 | 76 | addErrorCount(success); 77 | stopIterationOnFail("POST instance", success, res); 78 | 79 | const instanceId = res.json('id'); 80 | 81 | res = storageApi.postData(data.token, instanceId, "vedlegg", data.kattebilde); 82 | success = check(res, { 83 | "POST attachment kattebilde status is 201.": (r) => 84 | r.status === 201, 85 | }); 86 | 87 | addErrorCount(success); 88 | stopIterationOnFail("POST attachment kattebilde", success, res); 89 | 90 | const dataElementId = res.json('id'); 91 | 92 | res = storageApi.getInstance(data.token, instanceId); 93 | success = check(res, { 94 | "Get instance status is 200.": (r) => 95 | r.status === 200, 96 | }); 97 | addErrorCount(success); 98 | stopIterationOnFail("Get instance", success, res); 99 | 100 | let retrievedInstance = JSON.parse(res.body); 101 | let dataElements = retrievedInstance.data.filter(function (d) { 102 | return d.id == dataElementId; 103 | }); 104 | 105 | let dataElement = dataElements[0]; 106 | 107 | success = check(dataElement, { 108 | "GET check data element. Confirm that scan result is pending or clean.": 109 | dataElement.fileScanResult == "Pending" || dataElement.fileScanResult == "Clean", 110 | }); 111 | 112 | addErrorCount(success); 113 | sleep(15); 114 | 115 | res = storageApi.getInstance(data.token, instanceId); 116 | retrievedInstance = JSON.parse(res.body); 117 | dataElements = retrievedInstance.data.filter(function (d) { 118 | return d.id == dataElementId; 119 | }); 120 | 121 | dataElement = dataElements[0]; 122 | 123 | success = check(res, { 124 | "GET check data element. Confirm that scan result is clean.": 125 | dataElement.fileScanResult === "Clean", 126 | }); 127 | addErrorCount(success); 128 | 129 | // clean up instance 130 | res = storageApi.hardDeleteInstance(data.token, instanceId); 131 | 132 | success = check(res, { 133 | "DELETE hard delete instance after test status is 200.": (r) => 134 | r.status === 200, 135 | }); 136 | addErrorCount(success); 137 | } 138 | 139 | /* 140 | export function handleSummary(data) { 141 | let result = {}; 142 | result[reportPath("filescan.xml")] = generateJUnitXML(data, "platform-filescan"); 143 | 144 | return result; 145 | } 146 | */ 147 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/TestingControllers/DataElementControllerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http; 3 | using System.Net.Http.Headers; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | using Altinn.Common.AccessToken.Services; 8 | 9 | using Altinn.FileScan.Controllers; 10 | using Altinn.FileScan.Models; 11 | using Altinn.FileScan.Services.Interfaces; 12 | using Altinn.FileScan.Tests.Mocks; 13 | using Altinn.FileScan.Tests.Mocks.Authentication; 14 | using Altinn.FileScan.Tests.Utils; 15 | 16 | using AltinnCore.Authentication.JwtCookie; 17 | 18 | using Microsoft.AspNetCore.Mvc.Testing; 19 | using Microsoft.AspNetCore.TestHost; 20 | using Microsoft.Extensions.DependencyInjection; 21 | using Microsoft.Extensions.Options; 22 | 23 | using Moq; 24 | 25 | using Xunit; 26 | 27 | namespace Altinn.FileScan.Tests.TestingControllers; 28 | 29 | /// 30 | /// Represents a collection of tests of the . 31 | /// 32 | public class DataElementControllerTests : IClassFixture> 33 | { 34 | private const string BasePath = "/filescan/api/v1"; 35 | 36 | private readonly WebApplicationFactory _factory; 37 | private readonly string serializedDataElement; 38 | 39 | /// 40 | /// Initializes a new instance of the class with the given . 41 | /// 42 | /// The to use when setting up the test server. 43 | public DataElementControllerTests(WebApplicationFactory factory) 44 | { 45 | _factory = factory; 46 | serializedDataElement = "{" + 47 | "\"id\": \"11f7c994-6681-47a1-9626-fcf6c27308a5\"," + 48 | "\"instanceGuid\": \"649388f0-a2c0-4774-bd11-c870223ed819\"," + 49 | "\"dataType\": \"default\"," + 50 | "\"contentType\": \"text/plain; charset=utf-8\"," + 51 | "\"blobStoragePath\": \"tdd/endring-av-navn/649388f0-a2c0-4774-bd11-c870223ed819/data/11f7c994-6681-47a1-9626-fcf6c27308a5\"," + 52 | "\"size\": 19," + 53 | "\"locked\": false," + 54 | "\"created\": \"2020-05-11T17:09:28.4621953Z\"," + 55 | "\"lastChanged\": \"2020-05-11T17:09:28.4621953Z\"" + 56 | "}"; 57 | } 58 | 59 | /// 60 | /// Scenario: 61 | /// Post a request to ScanDataElement endpoint include platform access token 62 | /// Expected result: 63 | /// Returns HttpStatus Ok. 64 | /// Success criteria: 65 | /// The response has correct status code. 66 | /// 67 | [Fact] 68 | public async Task Post_ScanDataElement_PlatformAccessTokenIncluded() 69 | { 70 | // Arrange 71 | string requestUri = $"{BasePath}/dataelement"; 72 | var dataElementMock = new Mock(); 73 | dataElementMock 74 | .Setup(de => de.Scan(It.IsAny())); 75 | 76 | HttpClient client = GetTestClient(dataElementMock.Object); 77 | 78 | HttpRequestMessage httpRequestMessage = new(HttpMethod.Post, requestUri) 79 | { 80 | Content = new StringContent(serializedDataElement, Encoding.UTF8, "application/json") 81 | }; 82 | 83 | httpRequestMessage.Headers.Add("PlatformAccessToken", PrincipalUtil.GetAccessToken("platform", "file-scan")); 84 | 85 | // Act 86 | HttpResponseMessage response = await client.SendAsync(httpRequestMessage); 87 | 88 | // Assert 89 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 90 | } 91 | 92 | /// 93 | /// Scenario: 94 | /// Post a request to ScanDataElement endpoint ommit platform access token 95 | /// Expected result: 96 | /// Returns HttpStatus Forbidden. 97 | /// Success criteria: 98 | /// The response has correct status code. 99 | /// 100 | [Fact] 101 | public async Task Post_ScanDataElement_PlatformAccessTokenOmmited_BearerIncluded() 102 | { 103 | // Arrange 104 | string requestUri = $"{BasePath}/dataelement"; 105 | HttpClient client = GetTestClient(); 106 | client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetToken(1)); 107 | HttpRequestMessage httpRequestMessage = new(HttpMethod.Post, requestUri) 108 | { 109 | Content = new StringContent(serializedDataElement, Encoding.UTF8, "application/json") 110 | }; 111 | 112 | // Act 113 | HttpResponseMessage response = await client.SendAsync(httpRequestMessage); 114 | 115 | // Assert 116 | Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); 117 | } 118 | 119 | /// 120 | /// Scenario: 121 | /// Post a request to ScanDataElement endpoint ommit platform access token 122 | /// Expected result: 123 | /// Returns HttpStatus Forbidden. 124 | /// Success criteria: 125 | /// The response has correct status code. 126 | /// 127 | [Fact] 128 | public async Task Post_ScanDataElement_PlatformAccessTokenOmmited_BearerTokenPresent() 129 | { 130 | // Arrange 131 | string requestUri = $"{BasePath}/dataelement"; 132 | HttpClient client = GetTestClient(); 133 | HttpRequestMessage httpRequestMessage = new(HttpMethod.Post, requestUri) 134 | { 135 | Content = new StringContent(serializedDataElement, Encoding.UTF8, "application/json") 136 | }; 137 | 138 | // Act 139 | HttpResponseMessage response = await client.SendAsync(httpRequestMessage); 140 | 141 | // Assert 142 | Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); 143 | } 144 | 145 | private HttpClient GetTestClient(IDataElement dataElementMock = null) 146 | { 147 | dataElementMock ??= new Mock().Object; 148 | 149 | HttpClient client = _factory.WithWebHostBuilder(builder => 150 | { 151 | builder.ConfigureTestServices(services => 152 | { 153 | // Set up mock authentication so that not well known endpoint is used 154 | services.AddSingleton, JwtCookiePostConfigureOptionsStub>(); 155 | services.AddSingleton(); 156 | 157 | services.AddSingleton(dataElementMock); 158 | }); 159 | }).CreateClient(); 160 | 161 | return client; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /src/Altinn.FileScan/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | using Altinn.Common.AccessToken; 4 | using Altinn.Common.AccessToken.Configuration; 5 | using Altinn.Common.AccessToken.Services; 6 | using Altinn.Common.AccessTokenClient.Services; 7 | using Altinn.FileScan.Clients; 8 | using Altinn.FileScan.Clients.Interfaces; 9 | using Altinn.FileScan.Configuration; 10 | using Altinn.FileScan.Health; 11 | using Altinn.FileScan.Repository; 12 | using Altinn.FileScan.Repository.Interfaces; 13 | using Altinn.FileScan.Services; 14 | using Altinn.FileScan.Services.Interfaces; 15 | using Altinn.FileScan.Telemetry; 16 | using AltinnCore.Authentication.JwtCookie; 17 | 18 | using Azure.Identity; 19 | using Azure.Monitor.OpenTelemetry.Exporter; 20 | using Azure.Security.KeyVault.Secrets; 21 | 22 | using Microsoft.AspNetCore.Authorization; 23 | using Microsoft.IdentityModel.Tokens; 24 | using Microsoft.OpenApi.Models; 25 | using OpenTelemetry.Logs; 26 | using OpenTelemetry.Metrics; 27 | using OpenTelemetry.Resources; 28 | using OpenTelemetry.Trace; 29 | using Swashbuckle.AspNetCore.SwaggerGen; 30 | 31 | ILogger logger; 32 | 33 | string vaultApplicationInsightsKey = "ApplicationInsights--InstrumentationKey"; 34 | string applicationInsightsConnectionString = string.Empty; 35 | 36 | var builder = WebApplication.CreateBuilder(args); 37 | 38 | ConfigureWebHostCreationLogging(); 39 | 40 | await SetConfigurationProviders(builder.Configuration); 41 | 42 | ConfigureApplicationLogging(builder.Logging); 43 | 44 | ConfigureServices(builder.Services, builder.Configuration); 45 | 46 | var app = builder.Build(); 47 | 48 | Configure(); 49 | 50 | app.Run(); 51 | 52 | void ConfigureWebHostCreationLogging() 53 | { 54 | var logFactory = LoggerFactory.Create(builder => 55 | { 56 | builder 57 | .AddFilter("Altinn.FileScan.Program", LogLevel.Debug) 58 | .AddConsole(); 59 | }); 60 | 61 | logger = logFactory.CreateLogger(); 62 | } 63 | 64 | async Task SetConfigurationProviders(ConfigurationManager config) 65 | { 66 | string basePath = Directory.GetParent(Directory.GetCurrentDirectory()).FullName; 67 | 68 | config.SetBasePath(basePath); 69 | string configJsonFile1 = $"{basePath}/altinn-appsettings/altinn-dbsettings-secret.json"; 70 | string configJsonFile2 = $"{Directory.GetCurrentDirectory()}/appsettings.json"; 71 | 72 | if (basePath == "/") 73 | { 74 | configJsonFile2 = "/app/appsettings.json"; 75 | } 76 | 77 | config.AddJsonFile(configJsonFile1, optional: true, reloadOnChange: true); 78 | 79 | config.AddJsonFile(configJsonFile2, optional: false, reloadOnChange: true); 80 | 81 | config.AddEnvironmentVariables(); 82 | 83 | await ConnectToKeyVaultAndSetApplicationInsights(config); 84 | 85 | config.AddCommandLine(args); 86 | } 87 | 88 | async Task ConnectToKeyVaultAndSetApplicationInsights(ConfigurationManager config) 89 | { 90 | KeyVaultSettings keyVaultSettings = new(); 91 | config.GetSection("kvSetting").Bind(keyVaultSettings); 92 | if (!string.IsNullOrEmpty(keyVaultSettings.SecretUri)) 93 | { 94 | logger.LogInformation("Program // Configure key vault client // App"); 95 | 96 | DefaultAzureCredential azureCredentials = new(); 97 | 98 | config.AddAzureKeyVault(new Uri(keyVaultSettings.SecretUri), azureCredentials); 99 | 100 | SecretClient client = new(new Uri(keyVaultSettings.SecretUri), azureCredentials); 101 | 102 | try 103 | { 104 | KeyVaultSecret keyVaultSecret = await client.GetSecretAsync(vaultApplicationInsightsKey); 105 | applicationInsightsConnectionString = string.Format("InstrumentationKey={0}", keyVaultSecret.Value); 106 | } 107 | catch (Exception vaultException) 108 | { 109 | logger.LogError(vaultException, $"Unable to read application insights key."); 110 | } 111 | } 112 | } 113 | 114 | void ConfigureApplicationLogging(ILoggingBuilder logging) 115 | { 116 | logging.AddOpenTelemetry(builder => 117 | { 118 | builder.IncludeFormattedMessage = true; 119 | builder.IncludeScopes = true; 120 | }); 121 | } 122 | 123 | void ConfigureServices(IServiceCollection services, IConfiguration config) 124 | { 125 | logger.LogInformation("Program // ConfigureServices"); 126 | 127 | var attributes = new List>(2) 128 | { 129 | KeyValuePair.Create("service.name", (object)"platform-filescan"), 130 | }; 131 | 132 | services.AddOpenTelemetry() 133 | .ConfigureResource(resourceBuilder => resourceBuilder.AddAttributes(attributes)) 134 | .WithMetrics(metrics => 135 | { 136 | metrics.AddAspNetCoreInstrumentation(); 137 | metrics.AddMeter( 138 | "Microsoft.AspNetCore.Hosting", 139 | "Microsoft.AspNetCore.Server.Kestrel", 140 | "System.Net.Http"); 141 | }) 142 | .WithTracing(tracing => 143 | { 144 | if (builder.Environment.IsDevelopment()) 145 | { 146 | tracing.SetSampler(new AlwaysOnSampler()); 147 | } 148 | 149 | tracing.AddAspNetCoreInstrumentation(); 150 | tracing.AddHttpClientInstrumentation(); 151 | tracing.AddProcessor(new RequestFilterProcessor(new HttpContextAccessor())); 152 | }); 153 | 154 | if (!string.IsNullOrEmpty(applicationInsightsConnectionString)) 155 | { 156 | AddAzureMonitorTelemetryExporters(services, applicationInsightsConnectionString); 157 | } 158 | 159 | services.AddControllers(); 160 | services.AddMemoryCache(); 161 | services.AddHealthChecks().AddCheck("filescan_health_check"); 162 | 163 | services.Configure(config.GetSection("PlatformSettings")); 164 | services.Configure(config.GetSection("kvSetting")); 165 | services.Configure(config.GetSection("AccessTokenSettings")); 166 | services.Configure(config.GetSection("AccessTokenSettings")); 167 | services.Configure(config.GetSection("AppOwnerAzureStorageConfig")); 168 | 169 | services.AddSingleton(); 170 | services.AddSingleton(); 171 | services.AddSingleton(); 172 | 173 | services.AddSingleton(); 174 | services.AddSingleton(); 175 | 176 | services.AddSingleton(); 177 | 178 | services.AddSingleton(); 179 | services.AddSingleton(); 180 | services.AddSingleton(); 181 | services.AddSingleton(); 182 | 183 | services.AddHttpClient(); 184 | services.AddHttpClient(); 185 | 186 | services.AddAuthentication(JwtCookieDefaults.AuthenticationScheme) 187 | .AddJwtCookie(JwtCookieDefaults.AuthenticationScheme, options => 188 | { 189 | GeneralSettings generalSettings = config.GetSection("GeneralSettings").Get(); 190 | options.JwtCookieName = generalSettings.JwtCookieName; 191 | options.MetadataAddress = generalSettings.OpenIdWellKnownEndpoint; 192 | options.TokenValidationParameters = new TokenValidationParameters 193 | { 194 | ValidateIssuerSigningKey = true, 195 | ValidateIssuer = false, 196 | ValidateAudience = false, 197 | RequireExpirationTime = true, 198 | ValidateLifetime = true, 199 | ClockSkew = TimeSpan.Zero 200 | }; 201 | 202 | if (builder.Environment.IsDevelopment()) 203 | { 204 | options.RequireHttpsMetadata = false; 205 | } 206 | }); 207 | 208 | services.AddAuthorizationBuilder() 209 | .AddPolicy("PlatformAccess", policy => policy.Requirements.Add(new AccessTokenRequirement())); 210 | 211 | services.AddSwaggerGen(swaggerGenOptions => AddSwaggerGen(swaggerGenOptions)); 212 | } 213 | 214 | void AddSwaggerGen(SwaggerGenOptions swaggerGenOptions) 215 | { 216 | swaggerGenOptions.SwaggerDoc("v1", new OpenApiInfo { Title = "Altinn FileScan", Version = "v1" }); 217 | 218 | try 219 | { 220 | string xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; 221 | string xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); 222 | swaggerGenOptions.IncludeXmlComments(xmlPath); 223 | } 224 | catch (Exception e) 225 | { 226 | logger.LogWarning(e, "Program // Exception when attempting to include the XML comments file."); 227 | } 228 | } 229 | 230 | void AddAzureMonitorTelemetryExporters(IServiceCollection services, string applicationInsightsConnectionString) 231 | { 232 | services.Configure(logging => logging.AddAzureMonitorLogExporter(o => 233 | { 234 | o.ConnectionString = applicationInsightsConnectionString; 235 | })); 236 | services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddAzureMonitorMetricExporter(o => 237 | { 238 | o.ConnectionString = applicationInsightsConnectionString; 239 | })); 240 | services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddAzureMonitorTraceExporter(o => 241 | { 242 | o.ConnectionString = applicationInsightsConnectionString; 243 | })); 244 | } 245 | 246 | void Configure() 247 | { 248 | logger.LogInformation("Program // Configure {appName}", app.Environment.ApplicationName); 249 | 250 | if (app.Environment.IsDevelopment() || app.Environment.IsStaging()) 251 | { 252 | app.UseDeveloperExceptionPage(); 253 | app.UseSwagger(o => o.RouteTemplate = "filescan/swagger/{documentName}/swagger.json"); 254 | 255 | app.UseSwaggerUI(c => 256 | { 257 | c.SwaggerEndpoint("/filescan/swagger/v1/swagger.json", "Altinn FileScan API"); 258 | c.RoutePrefix = "filescan/swagger"; 259 | }); 260 | } 261 | else 262 | { 263 | app.UseExceptionHandler("/filescan/api/v1/error"); 264 | } 265 | 266 | app.UseRouting(); 267 | app.UseAuthentication(); 268 | app.UseAuthorization(); 269 | 270 | app.MapControllers(); 271 | app.MapHealthChecks("/health"); 272 | } 273 | -------------------------------------------------------------------------------- /test/Altinn.FileScan.Tests/TestingServices/DataElementServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Altinn.FileScan.Clients.Interfaces; 7 | using Altinn.FileScan.Exceptions; 8 | using Altinn.FileScan.Models; 9 | using Altinn.FileScan.Repository.Interfaces; 10 | using Altinn.FileScan.Services; 11 | using Altinn.FileScan.Services.Interfaces; 12 | using Altinn.Platform.Storage.Interface.Enums; 13 | using Altinn.Platform.Storage.Interface.Models; 14 | using Microsoft.Extensions.Logging; 15 | using Moq; 16 | using Xunit; 17 | 18 | namespace Altinn.FileScan.Tests.TestingServices 19 | { 20 | public class DataElementServiceTests 21 | { 22 | private static DateTimeOffset requestTimestamp = new DateTimeOffset(2023, 1, 10, 8, 0, 0, new TimeSpan(0, 0, 0)); 23 | private static DateTimeOffset matchingTimestamp = new DateTimeOffset(2023, 1, 10, 8, 0, 0, new TimeSpan(0, 0, 0)); 24 | private static DateTimeOffset nonMatchingTimestamp = new DateTimeOffset(2023, 1, 10, 8, 30, 0, new TimeSpan(0, 0, 0)); 25 | 26 | private DataElementScanRequest _defaultDataElementScanRequest = new DataElementScanRequest 27 | { 28 | BlobStoragePath = "blobstoragePath/org/attachment.pdf", 29 | DataElementId = "dataElementId", 30 | InstanceId = "instanceId", 31 | Timestamp = requestTimestamp, 32 | Filename = "attachment.pdf", 33 | Org = "ttd" 34 | }; 35 | 36 | [Fact] 37 | public async Task Scan_HappyPath_AllServicesAreCalledWithExpectedData() 38 | { 39 | // Arrange 40 | Mock blobMock = new(); 41 | blobMock 42 | .Setup(b => b.GetBlobProperties(It.IsAny(), It.IsAny(), It.IsAny())) 43 | .ReturnsAsync(new BlobPropertyModel 44 | { 45 | LastModified = matchingTimestamp 46 | }); 47 | blobMock 48 | .Setup(b => b.GetBlob(It.Is(s => s == "ttd"), It.Is(s => s == "blobstoragePath/org/attachment.pdf"), null)) 49 | .ReturnsAsync((Stream)null); 50 | 51 | Mock muescheliClientMock = new(); 52 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.IsAny())) 53 | .ReturnsAsync(ScanResult.OK); 54 | 55 | Mock storageClientMock = new(); 56 | storageClientMock 57 | .Setup(s => s.PatchFileScanStatus(It.Is(s => s == "instanceId"), It.Is(s => s == "dataElementId"), It.Is(f => f.FileScanResult == FileScanResult.Clean))); 58 | 59 | DataElementService sut = SetUpTestService(blobMock.Object, muescheliClientMock.Object, storageClientMock.Object); 60 | 61 | // Act 62 | await sut.Scan(_defaultDataElementScanRequest); 63 | 64 | // Assert 65 | blobMock.VerifyAll(); 66 | muescheliClientMock.VerifyAll(); 67 | storageClientMock.VerifyAll(); 68 | } 69 | 70 | [Fact] 71 | public async Task Scan_FilenameMissing_DataElementIdIsUsed() 72 | { 73 | // Arrange 74 | Mock muescheliClientMock = new(); 75 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.Is(s => s == "dataElementId.bin"))) 76 | .ReturnsAsync(ScanResult.OK); 77 | 78 | DataElementService sut = SetUpTestService(muescheliClient: muescheliClientMock.Object); 79 | 80 | var input = new DataElementScanRequest 81 | { 82 | BlobStoragePath = "blobstoragePath/org/attachment.pdf", 83 | DataElementId = "dataElementId", 84 | Timestamp = requestTimestamp, 85 | InstanceId = "instanceId", 86 | Org = "ttd" 87 | }; 88 | 89 | // Act 90 | await sut.Scan(input); 91 | 92 | // Assert 93 | muescheliClientMock.VerifyAll(); 94 | } 95 | 96 | [Fact] 97 | public async Task Scan_TimestampDoesNotMatch_ErrorLogged() 98 | { 99 | // Arrange 100 | Mock blobMock = new(); 101 | blobMock 102 | .Setup(b => b.GetBlobProperties(It.IsAny(), It.IsAny(), It.IsAny())) 103 | .ReturnsAsync(new BlobPropertyModel { LastModified = nonMatchingTimestamp }); 104 | blobMock 105 | .Setup(b => b.GetBlob(It.IsAny(), It.IsAny(), null)) 106 | .ReturnsAsync((Stream)null); 107 | 108 | Mock> loggerMock = new Mock>(); 109 | 110 | Mock muescheliClientMock = new(); 111 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.Is(s => s == "dataElementId.txt"))) 112 | .ReturnsAsync(ScanResult.OK); 113 | 114 | DataElementService sut = SetUpTestService( 115 | appOwnerBlob: blobMock.Object, 116 | muescheliClient: muescheliClientMock.Object, 117 | logger: loggerMock.Object); 118 | 119 | var input = new DataElementScanRequest 120 | { 121 | BlobStoragePath = "blobstoragePath/org/attachment.pdf", 122 | DataElementId = "dataElementId", 123 | Timestamp = requestTimestamp, 124 | InstanceId = "instanceId", 125 | Org = "ttd" 126 | }; 127 | 128 | // Act 129 | await sut.Scan(input); 130 | 131 | // Assert 132 | loggerMock.Verify(x => x.Log(LogLevel.Error, It.IsAny(), It.IsAny(), It.IsAny(), (Func)It.IsAny()), Times.Once); 133 | muescheliClientMock.Verify(x => x.ScanStream(It.IsAny(), It.IsAny()), Times.Never); 134 | } 135 | 136 | [Theory] 137 | [InlineData(ScanResult.OK, FileScanResult.Clean)] 138 | [InlineData(ScanResult.FOUND, FileScanResult.Infected)] 139 | public async Task Scan_DeterminateScanResult_MappedCorrectlyToFileSCcanResult(ScanResult scanResult, FileScanResult expectedFileScanResult) 140 | { 141 | // Arraange 142 | Mock muescheliClientMock = new(); 143 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.IsAny())) 144 | .ReturnsAsync(scanResult); 145 | 146 | Mock storageClientMock = new(); 147 | storageClientMock 148 | .Setup(s => s.PatchFileScanStatus(It.IsAny(), It.IsAny(), It.Is(f => f.FileScanResult == expectedFileScanResult))); 149 | 150 | DataElementService sut = SetUpTestService(muescheliClient: muescheliClientMock.Object, storageClient: storageClientMock.Object); 151 | 152 | // Act 153 | await sut.Scan(_defaultDataElementScanRequest); 154 | 155 | // Assert 156 | muescheliClientMock.VerifyAll(); 157 | storageClientMock.VerifyAll(); 158 | } 159 | 160 | [Theory] 161 | [InlineData(ScanResult.UNDEFINED)] 162 | [InlineData(ScanResult.ERROR)] 163 | [InlineData(ScanResult.PARSE_ERROR)] 164 | public async Task Scan_IndeterminateScanResult_ExceptionIsThrown(ScanResult scanResult) 165 | { 166 | // Arraange 167 | Mock muescheliClientMock = new(); 168 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.IsAny())) 169 | .ReturnsAsync(scanResult); 170 | 171 | DataElementService sut = SetUpTestService(muescheliClient: muescheliClientMock.Object); 172 | 173 | // Act 174 | Task Act() => sut.Scan(_defaultDataElementScanRequest); 175 | 176 | // Assert 177 | MuescheliScanResultException exception = await Assert.ThrowsAsync(Act); 178 | Assert.Equal($"Muescheli scan returned result code `{scanResult}` for data element with id dataElementId", exception.Message); 179 | } 180 | 181 | [Fact] 182 | public async Task Scan_MuscliClientThrowsException_ExceptionBubblesUp() 183 | { 184 | // Arraange 185 | Mock muescheliClientMock = new(); 186 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.IsAny())) 187 | .ThrowsAsync(await MuescheliHttpException.CreateAsync(HttpStatusCode.NotFound, new HttpResponseMessage(HttpStatusCode.NotFound))); 188 | 189 | DataElementService sut = SetUpTestService(muescheliClient: muescheliClientMock.Object); 190 | 191 | // Act 192 | Task Act() => sut.Scan(_defaultDataElementScanRequest); 193 | 194 | // Assert 195 | MuescheliHttpException exception = await Assert.ThrowsAsync(Act); 196 | } 197 | 198 | private static DataElementService SetUpTestService( 199 | IAppOwnerBlob appOwnerBlob = null, 200 | IMuescheliClient muescheliClient = null, 201 | IStorageClient storageClient = null, 202 | ILogger logger = null) 203 | { 204 | if (appOwnerBlob is null) 205 | { 206 | Mock blobMock = new(); 207 | blobMock 208 | .Setup(b => b.GetBlobProperties(It.IsAny(), It.IsAny(), It.IsAny())) 209 | .ReturnsAsync(new BlobPropertyModel 210 | { 211 | LastModified = matchingTimestamp 212 | }); 213 | blobMock 214 | .Setup(b => b.GetBlob(It.IsAny(), It.IsAny(), null)) 215 | .ReturnsAsync((Stream)null); 216 | 217 | appOwnerBlob = blobMock.Object; 218 | } 219 | 220 | if (muescheliClient is null) 221 | { 222 | Mock muescheliClientMock = new(); 223 | muescheliClientMock.Setup(m => m.ScanStream(It.IsAny(), It.IsAny())) 224 | .ReturnsAsync(ScanResult.OK); 225 | 226 | muescheliClient = muescheliClientMock.Object; 227 | } 228 | 229 | if (storageClient is null) 230 | { 231 | Mock storageClientMock = new(); 232 | storageClientMock 233 | .Setup(s => s.PatchFileScanStatus(It.IsAny(), It.IsAny(), It.IsAny())); 234 | 235 | storageClient = storageClientMock.Object; 236 | } 237 | 238 | if (logger == null) 239 | { 240 | logger = new Mock>().Object; 241 | } 242 | 243 | return new DataElementService(appOwnerBlob, muescheliClient, storageClient, logger); 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Remove the line below if you want to inherit .editorconfig settings from higher directories 2 | root = true 3 | 4 | #### Naming styles #### 5 | 6 | [*.cs] 7 | # Naming rules 8 | 9 | dotnet_naming_rule.private_or_internal_field_should_be_begins_with__.severity = suggestion 10 | dotnet_naming_rule.private_or_internal_field_should_be_begins_with__.symbols = private_or_internal_field 11 | dotnet_naming_rule.private_or_internal_field_should_be_begins_with__.style = begins_with__ 12 | 13 | # Symbol specifications 14 | 15 | dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field 16 | dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected 17 | dotnet_naming_symbols.private_or_internal_field.required_modifiers = 18 | 19 | # Naming styles 20 | 21 | dotnet_naming_style.begins_with__.required_prefix = _ 22 | dotnet_naming_style.begins_with__.required_suffix = 23 | dotnet_naming_style.begins_with__.word_separator = 24 | dotnet_naming_style.begins_with__.capitalization = camel_case 25 | csharp_indent_labels = one_less_than_current 26 | csharp_using_directive_placement = outside_namespace:warning 27 | csharp_prefer_simple_using_statement = true:suggestion 28 | csharp_prefer_braces = true:silent 29 | csharp_style_namespace_declarations = block_scoped:silent 30 | csharp_style_prefer_method_group_conversion = true:silent 31 | csharp_style_prefer_top_level_statements = true:silent 32 | csharp_style_expression_bodied_methods = false:silent 33 | csharp_style_expression_bodied_constructors = false:silent 34 | csharp_style_expression_bodied_operators = false:silent 35 | csharp_style_expression_bodied_properties = true:silent 36 | csharp_style_expression_bodied_indexers = true:silent 37 | csharp_style_expression_bodied_accessors = true:silent 38 | csharp_style_expression_bodied_lambdas = true:silent 39 | csharp_style_expression_bodied_local_functions = false:silent 40 | csharp_style_throw_expression = true:suggestion 41 | csharp_style_prefer_null_check_over_type_check = true:suggestion 42 | csharp_prefer_simple_default_expression = true:suggestion 43 | csharp_space_around_binary_operators = before_and_after 44 | csharp_style_prefer_primary_constructors = true:suggestion 45 | 46 | [*.{cs,vb}] 47 | #### Naming styles #### 48 | 49 | # Naming rules 50 | 51 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 52 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 53 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 54 | 55 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 56 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 57 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 58 | 59 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 60 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 61 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 62 | 63 | # Symbol specifications 64 | 65 | dotnet_naming_symbols.interface.applicable_kinds = interface 66 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 67 | dotnet_naming_symbols.interface.required_modifiers = 68 | 69 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 70 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 71 | dotnet_naming_symbols.types.required_modifiers = 72 | 73 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 74 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 75 | dotnet_naming_symbols.non_field_members.required_modifiers = 76 | 77 | # Naming styles 78 | 79 | dotnet_naming_style.begins_with_i.required_prefix = I 80 | dotnet_naming_style.begins_with_i.required_suffix = 81 | dotnet_naming_style.begins_with_i.word_separator = 82 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 83 | 84 | dotnet_naming_style.pascal_case.required_prefix = 85 | dotnet_naming_style.pascal_case.required_suffix = 86 | dotnet_naming_style.pascal_case.word_separator = 87 | dotnet_naming_style.pascal_case.capitalization = pascal_case 88 | 89 | dotnet_naming_style.pascal_case.required_prefix = 90 | dotnet_naming_style.pascal_case.required_suffix = 91 | dotnet_naming_style.pascal_case.word_separator = 92 | dotnet_naming_style.pascal_case.capitalization = pascal_case 93 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 94 | tab_width = 4 95 | indent_size = 4 96 | end_of_line = crlf 97 | dotnet_style_coalesce_expression = true:suggestion 98 | dotnet_style_null_propagation = true:suggestion 99 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 100 | dotnet_style_prefer_auto_properties = true:silent 101 | dotnet_style_object_initializer = true:suggestion 102 | dotnet_style_collection_initializer = true:suggestion 103 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 104 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 105 | dotnet_style_prefer_conditional_expression_over_return = true:silent 106 | dotnet_style_explicit_tuple_names = true:suggestion 107 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 108 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 109 | dotnet_style_prefer_compound_assignment = true:suggestion 110 | dotnet_style_prefer_simplified_interpolation = true:suggestion 111 | dotnet_style_namespace_match_folder = true:suggestion 112 | 113 | # StyleCop.Analyzers 114 | [*.{cs,vb}] 115 | 116 | dotnet_diagnostic.SA0001.severity = error 117 | dotnet_diagnostic.SA0002.severity = none 118 | dotnet_diagnostic.SA1000.severity = warning 119 | dotnet_diagnostic.SA1001.severity = warning 120 | dotnet_diagnostic.SA1002.severity = warning 121 | dotnet_diagnostic.SA1003.severity = warning 122 | dotnet_diagnostic.SA1004.severity = warning 123 | dotnet_diagnostic.SA1005.severity = warning 124 | dotnet_diagnostic.SA1006.severity = warning 125 | dotnet_diagnostic.SA1007.severity = warning 126 | dotnet_diagnostic.SA1008.severity = warning 127 | dotnet_diagnostic.SA1009.severity = warning 128 | dotnet_diagnostic.SA1010.severity = warning 129 | dotnet_diagnostic.SA1011.severity = warning 130 | dotnet_diagnostic.SA1012.severity = warning 131 | dotnet_diagnostic.SA1013.severity = warning 132 | dotnet_diagnostic.SA1014.severity = warning 133 | dotnet_diagnostic.SA1015.severity = warning 134 | dotnet_diagnostic.SA1016.severity = warning 135 | dotnet_diagnostic.SA1017.severity = warning 136 | dotnet_diagnostic.SA1018.severity = warning 137 | dotnet_diagnostic.SA1019.severity = warning 138 | dotnet_diagnostic.SA1020.severity = warning 139 | dotnet_diagnostic.SA1021.severity = warning 140 | dotnet_diagnostic.SA1022.severity = warning 141 | dotnet_diagnostic.SA1023.severity = warning 142 | dotnet_diagnostic.SA1024.severity = none 143 | dotnet_diagnostic.SA1025.severity = warning 144 | dotnet_diagnostic.SA1026.severity = warning 145 | dotnet_diagnostic.SA1027.severity = warning 146 | dotnet_diagnostic.SA1028.severity = none 147 | dotnet_diagnostic.SA1100.severity = error 148 | dotnet_diagnostic.SA1101.severity = none 149 | dotnet_diagnostic.SA1102.severity = error 150 | dotnet_diagnostic.SA1103.severity = error 151 | dotnet_diagnostic.SA1104.severity = error 152 | dotnet_diagnostic.SA1105.severity = error 153 | dotnet_diagnostic.SA1106.severity = error 154 | dotnet_diagnostic.SA1107.severity = error 155 | dotnet_diagnostic.SA1108.severity = error 156 | dotnet_diagnostic.SA1110.severity = error 157 | dotnet_diagnostic.SA1111.severity = error 158 | dotnet_diagnostic.SA1112.severity = error 159 | dotnet_diagnostic.SA1113.severity = error 160 | dotnet_diagnostic.SA1114.severity = error 161 | dotnet_diagnostic.SA1115.severity = error 162 | dotnet_diagnostic.SA1116.severity = error 163 | dotnet_diagnostic.SA1117.severity = error 164 | dotnet_diagnostic.SA1118.severity = error 165 | dotnet_diagnostic.SA1119.severity = error 166 | dotnet_diagnostic.SA1120.severity = error 167 | dotnet_diagnostic.SA1121.severity = error 168 | dotnet_diagnostic.SA1122.severity = error 169 | dotnet_diagnostic.SA1123.severity = none 170 | dotnet_diagnostic.SA1124.severity = none 171 | dotnet_diagnostic.SA1125.severity = error 172 | dotnet_diagnostic.SA1127.severity = error 173 | dotnet_diagnostic.SA1128.severity = none 174 | dotnet_diagnostic.SA1129.severity = error 175 | dotnet_diagnostic.SA1130.severity = error 176 | dotnet_diagnostic.SA1131.severity = error 177 | dotnet_diagnostic.SA1132.severity = error 178 | dotnet_diagnostic.SA1133.severity = error 179 | dotnet_diagnostic.SA1134.severity = error 180 | dotnet_diagnostic.SA1135.severity = error 181 | dotnet_diagnostic.SA1136.severity = error 182 | dotnet_diagnostic.SA1137.severity = warning 183 | dotnet_diagnostic.SA1139.severity = none 184 | dotnet_diagnostic.SA1200.severity = none 185 | dotnet_diagnostic.SA1201.severity = none 186 | dotnet_diagnostic.SA1202.severity = none 187 | dotnet_diagnostic.SA1203.severity = none 188 | dotnet_diagnostic.SA1204.severity = none 189 | dotnet_diagnostic.SA1205.severity = none 190 | dotnet_diagnostic.SA1206.severity = none 191 | dotnet_diagnostic.SA1207.severity = none 192 | dotnet_diagnostic.SA1208.severity = error 193 | dotnet_diagnostic.SA1209.severity = error 194 | dotnet_diagnostic.SA1210.severity = error 195 | dotnet_diagnostic.SA1211.severity = error 196 | dotnet_diagnostic.SA1212.severity = none 197 | dotnet_diagnostic.SA1213.severity = none 198 | dotnet_diagnostic.SA1214.severity = none 199 | dotnet_diagnostic.SA1216.severity = error 200 | dotnet_diagnostic.SA1217.severity = error 201 | dotnet_diagnostic.SA1300.severity = error 202 | dotnet_diagnostic.SA1302.severity = error 203 | dotnet_diagnostic.SA1303.severity = error 204 | dotnet_diagnostic.SA1304.severity = error 205 | dotnet_diagnostic.SA1305.severity = error 206 | dotnet_diagnostic.SA1306.severity = error 207 | dotnet_diagnostic.SA1307.severity = error 208 | dotnet_diagnostic.SA1308.severity = error 209 | dotnet_diagnostic.SA1309.severity = none 210 | dotnet_diagnostic.SA1310.severity = none 211 | dotnet_diagnostic.SA1311.severity = error 212 | dotnet_diagnostic.SA1312.severity = error 213 | dotnet_diagnostic.SA1313.severity = none 214 | dotnet_diagnostic.SA1314.severity = error 215 | dotnet_diagnostic.SA1400.severity = warning 216 | dotnet_diagnostic.SA1401.severity = error 217 | dotnet_diagnostic.SA1402.severity = none 218 | dotnet_diagnostic.SA1403.severity = error 219 | dotnet_diagnostic.SA1404.severity = error 220 | dotnet_diagnostic.SA1405.severity = none 221 | dotnet_diagnostic.SA1406.severity = none 222 | dotnet_diagnostic.SA1407.severity = error 223 | dotnet_diagnostic.SA1408.severity = error 224 | dotnet_diagnostic.SA1410.severity = none 225 | dotnet_diagnostic.SA1411.severity = none 226 | dotnet_diagnostic.SA1413.severity = none 227 | dotnet_diagnostic.SA1500.severity = error 228 | dotnet_diagnostic.SA1501.severity = error 229 | dotnet_diagnostic.SA1502.severity = error 230 | dotnet_diagnostic.SA1503.severity = error 231 | dotnet_diagnostic.SA1504.severity = error 232 | dotnet_diagnostic.SA1505.severity = error 233 | dotnet_diagnostic.SA1506.severity = error 234 | dotnet_diagnostic.SA1507.severity = error 235 | dotnet_diagnostic.SA1508.severity = warning 236 | dotnet_diagnostic.SA1509.severity = warning 237 | dotnet_diagnostic.SA1510.severity = error 238 | dotnet_diagnostic.SA1511.severity = error 239 | dotnet_diagnostic.SA1512.severity = warning 240 | dotnet_diagnostic.SA1513.severity = error 241 | dotnet_diagnostic.SA1514.severity = error 242 | dotnet_diagnostic.SA1515.severity = error 243 | dotnet_diagnostic.SA1516.severity = error 244 | dotnet_diagnostic.SA1517.severity = error 245 | dotnet_diagnostic.SA1518.severity = error 246 | dotnet_diagnostic.SA1519.severity = error 247 | dotnet_diagnostic.SA1520.severity = error 248 | dotnet_diagnostic.SA1600.severity = error 249 | dotnet_diagnostic.SA1601.severity = error 250 | dotnet_diagnostic.SA1602.severity = none 251 | dotnet_diagnostic.SA1604.severity = error 252 | dotnet_diagnostic.SA1605.severity = error 253 | dotnet_diagnostic.SA1606.severity = error 254 | dotnet_diagnostic.SA1607.severity = error 255 | dotnet_diagnostic.SA1608.severity = error 256 | dotnet_diagnostic.SA1610.severity = error 257 | dotnet_diagnostic.SA1611.severity = none 258 | dotnet_diagnostic.SA1612.severity = error 259 | dotnet_diagnostic.SA1613.severity = error 260 | dotnet_diagnostic.SA1614.severity = error 261 | dotnet_diagnostic.SA1615.severity = none 262 | dotnet_diagnostic.SA1616.severity = none 263 | dotnet_diagnostic.SA1617.severity = error 264 | dotnet_diagnostic.SA1618.severity = none 265 | dotnet_diagnostic.SA1619.severity = none 266 | dotnet_diagnostic.SA1620.severity = none 267 | dotnet_diagnostic.SA1621.severity = error 268 | dotnet_diagnostic.SA1622.severity = none 269 | dotnet_diagnostic.SA1623.severity = none 270 | dotnet_diagnostic.SA1624.severity = error 271 | dotnet_diagnostic.SA1625.severity = error 272 | dotnet_diagnostic.SA1626.severity = warning 273 | dotnet_diagnostic.SA1627.severity = error 274 | dotnet_diagnostic.SA1629.severity = none 275 | dotnet_diagnostic.SA1633.severity = none 276 | dotnet_diagnostic.SA1634.severity = none 277 | dotnet_diagnostic.SA1635.severity = none 278 | dotnet_diagnostic.SA1636.severity = none 279 | dotnet_diagnostic.SA1637.severity = none 280 | dotnet_diagnostic.SA1638.severity = none 281 | dotnet_diagnostic.SA1640.severity = none 282 | dotnet_diagnostic.SA1641.severity = none 283 | dotnet_diagnostic.SA1642.severity = none 284 | dotnet_diagnostic.SA1643.severity = none 285 | dotnet_diagnostic.SA1648.severity = error 286 | dotnet_diagnostic.SA1649.severity = error 287 | dotnet_diagnostic.SA1651.severity = error 288 | insert_final_newline = true 289 | --------------------------------------------------------------------------------