├── .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 | [](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