diff --git a/README.md b/README.md index 1a640327..02ef0578 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,13 @@ only and should not be used in production. ContainerDb(backend-db, "CouchDB", "CMS-Backend-Database") } } + + Container(trustedprovider, "Trusted Provider", "nginx + go", "Trusted CSAF provider") } Rel(user, reverseproxy,"","HTTPS") Rel(reverseproxy, secvisogram,"/") + Rel(reverseproxy, trustedprovider,"/.well-known/csaf") Rel(reverseproxy, oauth,"/api/*") Rel(reverseproxy, keycloak,"/realm/csaf/") Rel(oauth, validator, "/api/v1/test") @@ -123,6 +126,7 @@ only and should not be used in production. Rel(backend, backend-db,"") Rel(backend, keycloak,"") Rel(keycloak, keycloak-db,"") + Rel(backend, trustedprovider,"/cgi-bin/csaf_provider.go/api/upload") ``` @@ -141,6 +145,9 @@ only and should not be used in production. - [Generate a cookie secret](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview#generating-a-cookie-secret) and paste it in `CSAF_COOKIE_SECRET`. - restart `docker compose down` and `docker compose up -d` +- The trusted CSAF provider can be initialized with `docker compose up trusted-provider-setup` + - The folder `docker/config/trustedprovider` contains example / development PGP keys. + - More details on configuring the trusted provider can be found [GoCSAF](https://github.com/gocsaf/csaf) - (required for exports) install [pandoc (tested with version 2.18)](https://pandoc.org/installing.html) as well as [weasyprint (tested with version 56.0)](https://weasyprint.org/) and make sure both are in your PATH @@ -323,10 +330,3 @@ These additional references should also help you: [(back to top)](#bsi-secvisogram-csaf-backend) -#### diagrams.net (formerly known as draw.io) - -- [diagrams.net](https://www.diagrams.net/) - -- [Intellij Integration](https://plugins.jetbrains.com/plugin/15635-diagrams-net-integration) - -[(back to top)](#bsi-secvisogram-csaf-backend) diff --git a/docker/compose.yaml b/docker/compose.yaml index 9d1aa181..579d67e7 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -25,6 +25,7 @@ services: aliases: - "couchdb.csaf.internal" + keycloak-db: image: postgres:17-alpine hostname: keycloak-db.csaf.internal @@ -164,9 +165,10 @@ services: OAUTH2_PROXY_EMAIL_DOMAINS: "*" ports: - "${CSAF_APP_EXTERNAL_PORT}:4180" -# Remove comments for the next two line if there are issues with connections beetween the containers and the host -# extra_hosts: -# - "host.docker.internal:host-gateway" +# Remove comments it there are issues with host.docker.internal on Linux +# On Linux you have to enable the host-gateway feature in docker daemon + extra_hosts: + - "host.docker.internal:host-gateway" restart: on-failure depends_on: keycloak: @@ -209,7 +211,6 @@ services: default: aliases: - "secvisogram.csaf.internal" - reverse-proxy: image: nginx:1.27-alpine @@ -233,3 +234,42 @@ services: condition: service_started validator: condition: service_healthy + + trusted-provider: + build: + context: ./container/trustedprovider + dockerfile: Dockerfile + hostname: provider.csaf.internal + env_file: + - .env + environment: + - PUID=1000 + - PGID=1001 + - TZ=Europe/Berlin + volumes: + - ./config/trustedprovider/provider-config.toml:/config/provider-config.toml:ro + - ./config/trustedprovider/private.asc:/config/private.asc:ro + - ./config/trustedprovider/public.asc:/config/public.asc:ro + - ./data/trustedprovider/:/data/ + healthcheck: + test: ["CMD-SHELL", "wget -O /dev/null http://127.0.0.1 || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + + trusted-provider-setup: + build: + context: ./container/uploader + dockerfile: Dockerfile + container_name: "trusted-provider-setup" + restart: "no" + profiles: [ "run_manually" ] + volumes: + - ./config/uploader/config-create.toml:/config/config.toml:ro + environment: + - OPTIONS=--config=/config/config.toml + entrypoint: ["/opt/csaf_uploader", "--config=/config/config.toml"] + depends_on: + trusted-provider: + condition: service_healthy + \ No newline at end of file diff --git a/docker/config/reverseproxy/nginx.conf b/docker/config/reverseproxy/nginx.conf index 745f022d..60ba3257 100644 --- a/docker/config/reverseproxy/nginx.conf +++ b/docker/config/reverseproxy/nginx.conf @@ -58,5 +58,20 @@ http { proxy_pass http://secvisogram.csaf.internal/; proxy_redirect off; } + + location /cgi-bin { + proxy_pass http://provider.csaf.internal/cgi-bin; + proxy_redirect off; + } + + location /.well-known/security.txt { + proxy_pass http://provider.csaf.internal/.well-known/security.txt; + proxy_redirect off; + } + + location /.well-known/csaf{ + proxy_pass http://provider.csaf.internal/.well-known/csaf; + proxy_redirect off; + } } } \ No newline at end of file diff --git a/docker/config/trustedprovider/private.asc b/docker/config/trustedprovider/private.asc new file mode 100644 index 00000000..8b15ecd1 --- /dev/null +++ b/docker/config/trustedprovider/private.asc @@ -0,0 +1,81 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +8ck+ALgenY4Ck5oVNS4= +=Raia +-----END PGP PRIVATE KEY BLOCK----- diff --git a/docker/config/trustedprovider/provider-config.toml b/docker/config/trustedprovider/provider-config.toml new file mode 100644 index 00000000..1fec29b7 --- /dev/null +++ b/docker/config/trustedprovider/provider-config.toml @@ -0,0 +1,102 @@ +# Set the authentication password for accessing the CSAF provider. +# It is essential that you set a secure password between the quotation marks. +# The default being no password set. +password = "secretpassword" + +# Set the path to the public OpenPGP key. +openpgp_public_key = "/config/public.asc" + +# Set the path to the private OpenPGP key. +openpgp_private_key = "/config/private.asc" + +# Specify the root folder. +folder = "/data/" + +# Specify the web folder. +web = "/data/html" + +# Allow sending a signature with the request. +# An additional input-field in the web interface will be shown +# to let user enter an ascii armored OpenPGP signature. +#upload_signature = false + +# Set the beginning of the URL where contents are accessible from the internet. +# If not set, the provider will read from the $SERVER_NAME variable. +# The following shows an example of a manually set prefix: +#canonical_url_prefix = "https://localhost" + +# Require users to use a password and a valid Client Certificate for write access. +#certificate_and_password = false + +# Allow the user to send the request without having to send a passphrase +# to unlock the the OpenPGP key. +# If set to true, the input-field in the web interface will be omitted. +#no_passphrase = false + +# Make the provider skip the validation of the uploaded CSAF document +# against the JSON schema. +#no_validation = false + +# Disable the experimental web interface. +#no_web_ui = true + +# Make the provider take the publisher from the CSAF document. +#dynamic_provider_metadata = false + +# Set the upload limit size of a file in bytes. +# The default is equivalent to 50 MiB. +#upload_limit = 52428800 + +# Set the issuer of the CA. +# If set, the provider restricts the writing permission and the +# access to the web-interface to users with the client certificates +# signed with this CA. +# The following shows an example. As default, none is set. +#issuer = "Example Company" + +# Make the provider write/update index.txt and changes.csv. +write_indices = true + +# Make the provider write a `CSAF:` entry into `security.txt`. +write_security = true + +# Set the TLP allowed to be send with the upload request +# (one or more of "csaf", "white", "amber", "green", "red"). +# The "csaf" entry lets the provider take the value from the CSAF document. +# These affect the list items in the web interface. +#tlps = ["csaf", "white", "amber", "green", "red"] + +# Make the provider create a ROLIE service document. +create_service_document = true + +# Make the provider create a ROLIE category document from a list of strings. +# If a list item starts with `expr:` +# the rest of the string is used as a JsonPath expression +# to extract a string from the incoming advisories. +# Strings not starting with `expr:` are taken verbatim. +# By default no category documents are created. +# This example provides an overview over the syntax, +# adjust the parameters depending on your setup. +#categories = ["Example Company Product A", "expr:document.lang"] + +# Make the provider use a remote validator service. Not used by default. +# This example provides an overview over the syntax, +# adjust the parameters depending on your setup. +#[remote_validator] +#url = "http://localhost:8082" +#presets = ["mandatory"] +#cache = "/var/lib/csaf/validations.db" + +[provider_metadata] +# Indicate that aggregators can list us. +list_on_CSAF_aggregators = true +# Indicate that aggregators can mirror us. +mirror_on_CSAF_aggregators = true + +# Set the publisher details. +[provider_metadata.publisher] +category = "vendor" +name = "Example Company" +namespace = "https://example.com" +issuing_authority = "We at Example Company are responsible for publishing and maintaining Product Y." +contact_details = "Example Company can be reached at contact_us@example.com, or via our website at https://www.example.com/contact." diff --git a/docker/config/trustedprovider/public.asc b/docker/config/trustedprovider/public.asc new file mode 100644 index 00000000..8d655545 --- /dev/null +++ b/docker/config/trustedprovider/public.asc @@ -0,0 +1,40 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=LzLH +-----END PGP PUBLIC KEY BLOCK----- diff --git a/docker/config/uploader/config-create.toml b/docker/config/uploader/config-create.toml new file mode 100644 index 00000000..eaed5503 --- /dev/null +++ b/docker/config/uploader/config-create.toml @@ -0,0 +1,14 @@ +action = "create" +url = "http://provider.csaf.internal/cgi-bin/csaf_provider.go" +tlp = "csaf" +external_signed = false +no_schema_check = false +# key = "/path/to/openpgp/key/file" # not set by default +password = "secretpassword" # not set by default +# passphrase = "OpenPGP passphrase" # not set by default +# client_cert = "/path/to/client/cert" # not set by default +# client_key = "/path/to/client/cert.key" # not set by default +# client_passphrase = "client cert passphrase" # not set by default +password_interactive = false +passphrase_interactive = false +insecure = true diff --git a/docker/container/trustedprovider/Dockerfile b/docker/container/trustedprovider/Dockerfile new file mode 100644 index 00000000..6355c685 --- /dev/null +++ b/docker/container/trustedprovider/Dockerfile @@ -0,0 +1,25 @@ +ARG GO_VERSION=1.25.3 +ARG ALPINE_VERSION=3.22 + +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS base + +RUN apk add bash make wget git +RUN mkdir /app +COPY compile.sh /app/compile.sh +WORKDIR /app +RUN bash /app/compile.sh + +# Create provider image +FROM nginx:alpine AS provider + +COPY --from=base /usr/local/go/ /usr/local/go/ +COPY --from=base /app/csaf_distribution/bin-linux-amd64/csaf_provider /usr/lib/cgi-bin/csaf_provider.go +COPY --from=base /version /version +COPY trustedprovider-nginx.conf /etc/nginx/conf.d/default.conf +COPY fcgiwarp /docker-entrypoint.d/40-fcgiwrap.sh + +ENV PATH="/usr/local/go/bin:${PATH}" + +RUN apk add --no-cache spawn-fcgi fcgiwrap + +EXPOSE 80 \ No newline at end of file diff --git a/docker/container/trustedprovider/compile.sh b/docker/container/trustedprovider/compile.sh new file mode 100644 index 00000000..6a5a5d42 --- /dev/null +++ b/docker/container/trustedprovider/compile.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +if [ -z "$TAG" ] +then + echo "Cloning latest version" + git clone https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 +else + echo "Cloning TAG $TAG" + git clone --branch $TAG https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 + if [ $? -ne 0 ] + then + echo "TAG $TAG not found. Cloning latest version" + git clone https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 + fi +fi + +cd /app/csaf_distribution/ +export PATH=$PATH:/usr/local/go/bin +make build_linux + +git describe --tags --always > /version diff --git a/docker/container/trustedprovider/fcgiwarp b/docker/container/trustedprovider/fcgiwarp new file mode 100755 index 00000000..ea67f8f1 --- /dev/null +++ b/docker/container/trustedprovider/fcgiwarp @@ -0,0 +1,3 @@ +#!/bin/sh +echo "start fcgi" +spawn-fcgi -s /var/run/fcgiwrap.socket -M 766 -u $PUID -g $PGID /usr/bin/fcgiwrap > /fcgi.log \ No newline at end of file diff --git a/docker/container/trustedprovider/root/etc/init.d/40-fcgiwarp b/docker/container/trustedprovider/root/etc/init.d/40-fcgiwarp new file mode 100755 index 00000000..20941054 --- /dev/null +++ b/docker/container/trustedprovider/root/etc/init.d/40-fcgiwarp @@ -0,0 +1,3 @@ +#!/bin/sh + +spawn-fcgi -s /var/run/fcgiwrap.socket -M 766 -u $PUID -g $PGID /usr/bin/fcgiwrap \ No newline at end of file diff --git a/docker/container/trustedprovider/trustedprovider-nginx.conf b/docker/container/trustedprovider/trustedprovider-nginx.conf new file mode 100644 index 00000000..ef692b88 --- /dev/null +++ b/docker/container/trustedprovider/trustedprovider-nginx.conf @@ -0,0 +1,55 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + root /data/html; + index index.html index.htm index.php; + + server_name _; + + client_max_body_size 0; + + error_log /var/log/nginx/error1.log; + + location / { + try_files $uri $uri/ /index.html /index.php?$args =404; + + # For atomic directory switches + disable_symlinks off; + + # directory listings + autoindex on; + } + + # Include this file on your nginx.conf to support debian cgi-bin scripts using + # fcgiwrap + location /cgi-bin/ { + # Disable gzip (it makes scripts feel slower since they have to complete + # before getting gzipped) + gzip off; + + # Set the root to /usr/lib (inside this location this means that we are + # giving access to the files under /usr/lib/cgi-bin) + root /usr/lib; + + # Fastcgi socket + fastcgi_pass unix:/var/run/fcgiwrap.socket; + + # Fastcgi parameters, include the standard ones + include /etc/nginx/fastcgi_params; + + fastcgi_split_path_info ^(.+\.go)(.*)$; + + # Adjust non standard parameters (SCRIPT_FILENAME) + fastcgi_param SCRIPT_FILENAME /usr/lib$fastcgi_script_name; + + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param CSAF_CONFIG /config/provider-config.toml; + + #fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify; + #fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn; + #fastcgi_param SSL_CLIENT_I_DN $ssl_client_i_dn; + } +} + + diff --git a/docker/container/uploader/Dockerfile b/docker/container/uploader/Dockerfile new file mode 100644 index 00000000..1f446a71 --- /dev/null +++ b/docker/container/uploader/Dockerfile @@ -0,0 +1,16 @@ +ARG GO_VERSION=1.25.3 +ARG ALPINE_VERSION=3.22 + +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS base + +RUN apk add bash make wget git +RUN mkdir /app +COPY compile.sh /app/compile.sh +WORKDIR /app +RUN bash /app/compile.sh + +# Create provider image +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS csaf_uploader + +COPY --from=base /app/csaf_distribution/bin-linux-amd64/csaf_uploader /opt/csaf_uploader +COPY --from=base /version /version diff --git a/docker/container/uploader/compile.sh b/docker/container/uploader/compile.sh new file mode 100644 index 00000000..6a5a5d42 --- /dev/null +++ b/docker/container/uploader/compile.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +if [ -z "$TAG" ] +then + echo "Cloning latest version" + git clone https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 +else + echo "Cloning TAG $TAG" + git clone --branch $TAG https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 + if [ $? -ne 0 ] + then + echo "TAG $TAG not found. Cloning latest version" + git clone https://github.com/csaf-poc/csaf_distribution.git >/dev/null 2>&1 + fi +fi + +cd /app/csaf_distribution/ +export PATH=$PATH:/usr/local/go/bin +make build_linux + +git describe --tags --always > /version diff --git a/docker/data/keycloak-db/.gitignore b/docker/data/keycloak-db/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/docker/data/keycloak-db/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/docker/data/trustedprovider/.gitignore b/docker/data/trustedprovider/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/docker/data/trustedprovider/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafAutoPublishConfiguration.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafAutoPublishConfiguration.java new file mode 100644 index 00000000..2a88b0b4 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafAutoPublishConfiguration.java @@ -0,0 +1,57 @@ +package de.bsi.secvisogram.csaf_cms_backend.config; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CsafAutoPublishConfiguration { + private boolean enabled = false; + private boolean enableInsecureTLS = false; + private String url = ""; + private String password = ""; + private String cron = "0 * * * * *"; + + public boolean isEnabled() { + return enabled; + } + + public CsafAutoPublishConfiguration setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public String getUrl() { + return url; + } + + public CsafAutoPublishConfiguration setUrl(String url) { + this.url = url; + return this; + } + + public String getPassword() { + return password; + } + + public CsafAutoPublishConfiguration setPassword(String password) { + this.password = password; + return this; + } + + public String getCron() { + return cron; + } + + public CsafAutoPublishConfiguration setCron(String cron) { + this.cron = cron; + return this; + } + + public boolean isEnableInsecureTLS() { + return enableInsecureTLS; + } + + public CsafAutoPublishConfiguration setEnableInsecureTLS(boolean enableInsecureTLS) { + this.enableInsecureTLS = enableInsecureTLS; + return this; + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafConfiguration.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafConfiguration.java index 63d02ab5..64094642 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafConfiguration.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/config/CsafConfiguration.java @@ -9,7 +9,8 @@ public class CsafConfiguration { private CsafSummaryConfiguration summary; private CsafVersioningConfiguration versioning; - + private CsafAutoPublishConfiguration autoPublish; + public CsafSummaryConfiguration getSummary() { return summary; } @@ -27,4 +28,12 @@ public CsafConfiguration setVersioning(CsafVersioningConfiguration versioning) { this.versioning = versioning; return this; } + + public CsafAutoPublishConfiguration getAutoPublish() { + return autoPublish; + } + + public void setAutoPublish(CsafAutoPublishConfiguration autoPublish) { + this.autoPublish = autoPublish; + } } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java index 3aa43f9d..1f0f16a1 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java @@ -850,7 +850,7 @@ public static JsonNode applyJsonPatchToNode(JsonNode patch, JsonNode source) { * @param timestamp2 the second timestamp * @return true if timestamp1 is chronologically before timestamp2, false otherwise */ - private static boolean timestampIsBefore(String timestamp1, String timestamp2) { + public static boolean timestampIsBefore(String timestamp1, String timestamp2) { LocalDateTime t1 = from(ISO_DATE_TIME.parse(timestamp1)); LocalDateTime t2 = from(ISO_DATE_TIME.parse(timestamp2)); return t1.isBefore(t2); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/WorkflowState.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/WorkflowState.java index 82bd634b..3707ab51 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/WorkflowState.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/WorkflowState.java @@ -5,7 +5,7 @@ public enum WorkflowState { Draft, Review, Approved, - RfPublication, + AutoPublish, Published } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateService.java index 4929e182..4b40eeb8 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/template/DocumentTemplateService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import java.io.IOException; +import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -29,7 +30,7 @@ public class DocumentTemplateService { */ @Secured({CsafRoles.ROLE_REGISTERED, CsafRoles.ROLE_AUDITOR}) public DocumentTemplateDescription[] getAllTemplates() throws IOException { - + Path templateFilePath = Path.of(templatesFile); String templatesJson = Files.readString(templateFilePath); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java index abfa9a57..bda8e7c3 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/rest/AdvisoryController.java @@ -953,6 +953,63 @@ public ResponseEntity setWorkflowStateToRfPublication( return changeWorkflowState(advisoryId, revision, WorkflowState.RfPublication, proposedTime, null); } + /** + * Change workflow state of a CSAF document to AutoPublish + * + * @param advisoryId advisoryId id of the CSAF document to change + * @param revision optimistic locking revision + * @param proposedTime optimistic locking revision + * @param documentTrackingStatus the new Document Tracking Status of the CSAF Document + * @return new optimistic locking revision + */ + @Operation(summary = "Change workflow state of an advisory to Published.", + tags = {"Advisory"}, + description = "Change the workflow state of the advisory with the given id to Published.") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Workflow state changed to Publication.", + content = { + @Content(mediaType = MediaType.TEXT_PLAIN_VALUE) + } + ), + @ApiResponse( + responseCode = "400", + description = "Advisory ID not found." + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized access to change workflow state." + ), + @ApiResponse( + responseCode = "422", + description = "Invalid formatted advisory." + ), + @ApiResponse( + responseCode = "500", + description = "Error during process the advisory." + ) + }) + @PatchMapping("/{advisoryId}/workflowstate/AutoPublish") + public ResponseEntity setWorkflowStateToAutoPublish( + @PathVariable + @Parameter(in = ParameterIn.PATH, description = "The ID of the advisory to change the workflow state of.") + String advisoryId, + @RequestParam @Parameter(description = "Optimistic locking revision.") + String revision, + @RequestParam(required = false) + @Parameter(description = "Proposed Time at which the publication should take place as ISO-8601 UTC string.") + String proposedTime, + @RequestParam(required = false) + @Parameter(description = "The new Document Tracking Status of the CSAF Document." + + " Only Interim and Final are allowed.") + DocumentTrackingStatus documentTrackingStatus + ) throws IOException { + LOG.debug("setWorkflowStateToAutoPublish"); + checkValidUuid(advisoryId); + return changeWorkflowState(advisoryId, revision, WorkflowState.AutoPublish, proposedTime, documentTrackingStatus); + } + /** * Change workflow state of a CSAF document to Published * diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index ff5fa421..1f228d5c 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ibm.cloud.sdk.core.service.exception.BadRequestException; import com.ibm.cloud.sdk.core.service.exception.NotFoundException; +import com.ibm.icu.text.SimpleDateFormat; import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; @@ -513,27 +514,28 @@ public Path exportAdvisory( final JsonNode csaf = advisoryNode.getCsaf(); RemoveIdHelper.removeCommentIds(csaf); final String csafDocument = csaf.toString(); - + final String filename = advisoryNode.getDocumentTrackingId() == null ? "advisory__" : advisoryNode.getDocumentTrackingId(); + // if format is JSON - write it to temporary file and return the path if (format == ExportFormat.JSON || format == null) { - final Path jsonFile = Files.createTempFile("advisory__", ".json"); + final Path jsonFile = Files.createTempFile(filename, ".json"); Files.writeString(jsonFile, csafDocument); return jsonFile; } else { // other formats have to start with an HTML export first final String htmlExport = javascriptExporter.createHtml(csafDocument); - final Path htmlFile = Files.createTempFile("advisory__", ".html"); + final Path htmlFile = Files.createTempFile(filename, ".html"); Files.writeString(htmlFile, htmlExport); if (format == ExportFormat.HTML) { // we already have an HTML file - done! return htmlFile; } else if (format == ExportFormat.Markdown && pandocService.isReady()) { - final Path markdownFile = Files.createTempFile("advisory__", ".md"); + final Path markdownFile = Files.createTempFile(filename, ".md"); pandocService.convert(htmlFile, markdownFile); Files.delete(htmlFile); return markdownFile; } else if (format == ExportFormat.PDF && weasyprintService.isReady()) { - final Path pdfFile = Files.createTempFile("advisory__", ".pdf"); + final Path pdfFile = Files.createTempFile(filename, ".pdf"); weasyprintService.convert(htmlFile, pdfFile); Files.delete(htmlFile); return pdfFile; @@ -545,7 +547,7 @@ public Path exportAdvisory( CsafExceptionKey.AdvisoryNotFound, HttpStatus.NOT_FOUND); } } - + /** * Changes the workflow state of the advisory to the given new WorkflowState * @@ -615,8 +617,33 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo // In this step we only want to check if the document would be valid if published but not change it yet. createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); } - - if (newWorkflowState == WorkflowState.Published) { + + if (newWorkflowState == WorkflowState.AutoPublish) { + if (proposedTime == null) { + proposedTime = existingAdvisoryNode.getDocumentTrackingCurrentReleaseDate(); + if (proposedTime == null) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'.000000000Z"); + proposedTime = sdf.format(new Date()); + } + } + + if (documentTrackingStatus == null) { + existingAdvisoryNode.setDocumentTrackingStatus(DocumentTrackingStatus.Final); + } + + if (existingAdvisoryNode.getDocumentDistributionTlp() == null) { + throw new CsafException("TLP-Level missing", CsafExceptionKey.AdvisoryValidationError); + } + //TODO: Check, if further checks for upload are needed + + existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); + if (existingAdvisoryNode.getLastMajorVersion() < 1) { + setFinalTrackingIdAndUrl(existingAdvisoryNode); + } + } + + if (newWorkflowState == WorkflowState.Published && (previousWorkflowState != WorkflowState.AutoPublish)) { + existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); if (existingAdvisoryNode.getLastMajorVersion() < 1) { setFinalTrackingIdAndUrl(existingAdvisoryNode); diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java index 9a4bd0ab..5f36fccb 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryWorkflowUtil.java @@ -249,7 +249,19 @@ static boolean canChangeWorkflow(String userToCheck, WorkflowState oldWorkflowSt if (oldWorkflowState == WorkflowState.RfPublication && newWorkflowState == WorkflowState.Published) { canBeChanged = hasRole(PUBLISHER, credentials); } + + if (oldWorkflowState == WorkflowState.RfPublication && newWorkflowState == WorkflowState.AutoPublish) { + canBeChanged = hasRole(PUBLISHER, credentials); + } + if (oldWorkflowState == WorkflowState.AutoPublish && newWorkflowState == WorkflowState.Draft) { + canBeChanged = hasRole(PUBLISHER, credentials); + } + + if (oldWorkflowState == WorkflowState.AutoPublish && newWorkflowState == WorkflowState.Published) { + canBeChanged = hasRole(PUBLISHER, credentials); + } + return canBeChanged; } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfig.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfig.java new file mode 100644 index 00000000..052894cd --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfig.java @@ -0,0 +1,69 @@ +package de.bsi.secvisogram.csaf_cms_backend.task; + +import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; +import java.util.Collection; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import org.apache.commons.lang3.concurrent.BasicThreadFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.concurrent.DelegatingSecurityContextScheduledExecutorService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@Configuration +@EnableScheduling +@ComponentScan(basePackages = {"de.bsi.secvisogram.csaf_cms_backend.task"}) +public class PublishConfig implements SchedulingConfigurer { + + @Autowired + private CsafConfiguration configuration; + private static final Logger LOG = LoggerFactory.getLogger(PublishConfig.class); + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + if (this.configuration.getAutoPublish() != null) { + if (this.configuration.getAutoPublish().isEnabled()) { + taskRegistrar.setScheduler(taskExecutor()); + taskRegistrar.addCronTask(task(), this.configuration.getAutoPublish().getCron()); + LOG.info("Autopublish activated. Task created with " + this.configuration.getAutoPublish().getCron()); + } + } + } + + private SecurityContext createSchedulerSecurityContext() { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + Collection authorities = AuthorityUtils.createAuthorityList("ROLE_publisher", "ROLE_auditor", "ROLE_registred"); + Authentication authentication = new UsernamePasswordAuthenticationToken( + "PublisherTask", + "Publisher", + authorities + ); + context.setAuthentication(authentication); + + return context; + } + + @Bean + Runnable task() { + return new PublishJob(); + } + + @Bean + Executor taskExecutor() { + ScheduledThreadPoolExecutor delegateExecutor = new ScheduledThreadPoolExecutor(1, new BasicThreadFactory.Builder().namingPattern("Publish-Job-%d").build()); + SecurityContext schedulerContext = createSchedulerSecurityContext(); + return new DelegatingSecurityContextScheduledExecutorService(delegateExecutor, schedulerContext); + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java new file mode 100644 index 00000000..eb2dd1b3 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJob.java @@ -0,0 +1,121 @@ +package de.bsi.secvisogram.csaf_cms_backend.task; + +import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.json.AdvisoryWrapper; +import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; +import de.bsi.secvisogram.csaf_cms_backend.model.ExportFormat; +import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.rest.AdvisoryController; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; +import de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryService; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import javax.net.ssl.SSLException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.netty.http.client.HttpClient; + +@Component +public class PublishJob implements Runnable { + private static final Logger LOG = LoggerFactory.getLogger(AdvisoryController.class); + + @Autowired + private AdvisoryService advisoryService; + + @Autowired + private CsafConfiguration configuration; + + @Override + public void run() { + try { + this.publishJob(); + } catch (CsafException | IOException | DatabaseException e) { + LOG.error("There was a problem when publishing advisories", e); + } + } + + public void publishJob() throws CsafException, IOException, DatabaseException { + LOG.info("AutoPublisher started"); + List advisoryList = this.advisoryService.getAdvisoryInformations(""); + for (AdvisoryInformationResponse advisory : advisoryList) { + if (advisory.getWorkflowState() == WorkflowState.AutoPublish) { + if (AdvisoryWrapper.timestampIsBefore(advisory.getCurrentReleaseDate(), + DateTimeFormatter.ISO_INSTANT.format(Instant.now()))) { + Path p = this.advisoryService.exportAdvisory(advisory.getAdvisoryId(), ExportFormat.JSON); + String trackingId = advisory.getDocumentTrackingId().toLowerCase(); + + final WebClient webClient = createWebClient(); + try { + webClient.post().uri(this.configuration.getAutoPublish().getUrl()) + .contentType(MediaType.MULTIPART_FORM_DATA).header("X-Csaf-Provider-Auth", getAuthenticationCode()) + .body(BodyInserters.fromMultipartData(fromFile(p, trackingId))).retrieve() +// TODO Check, if still needed for exception handling +// .onStatus(HttpStatus::isError, response -> { +// return Mono.error(new PublisherException( +// String.format("Failed! %s %s", response.statusCode(), response.bodyToMono(String.class)) +// )); +// }) + .bodyToMono(String.class).onErrorMap(throwable -> { + if (WebClientResponseException.class.isInstance(throwable)) { + WebClientResponseException wcre = (WebClientResponseException) throwable; + return new PublisherException(throwable.getMessage() + wcre.getResponseBodyAsString()); + } + return new PublisherException(throwable.getMessage()); + }).block(); + } catch (PublisherException pe) { + LOG.error(pe.getMessage()); + // Skip workflow state change. + continue; + } + + this.advisoryService.changeAdvisoryWorkflowState(advisory.getAdvisoryId(), advisory.getRevision(), + WorkflowState.Published, advisory.getCurrentReleaseDate(), DocumentTrackingStatus.Final); + } + } + } + } + + private MultiValueMap> fromFile(Path path, String trackingId) { + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + String header = String.format("form-data; name=\"%s\"; filename=\"%s.json\"", "csaf", trackingId); + builder.part("csaf", new FileSystemResource(path)).header("Content-Disposition", header); + builder.part("tlp", "csaf"); + return builder.build(); + } + + private WebClient createWebClient() throws SSLException { + HttpClient httpClient = null; + if (this.configuration.getAutoPublish().isEnableInsecureTLS()) { + SslContext sslContext = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build(); + httpClient = HttpClient.create().secure(t -> t.sslContext(sslContext)); + } else { + httpClient = HttpClient.create(); + } + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build(); + } + + private String getAuthenticationCode() { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + return encoder.encode(this.configuration.getAutoPublish().getPassword()); + } +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublisherException.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublisherException.java new file mode 100644 index 00000000..701fb35e --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/task/PublisherException.java @@ -0,0 +1,10 @@ +package de.bsi.secvisogram.csaf_cms_backend.task; + +public class PublisherException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public PublisherException(String msg) { + super(msg); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5a27da36..b8fc1297 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,7 +24,7 @@ csaf.couchdb.password=${CSAF_COUCHDB_PASSWORD:admin} spring.security.oauth2.resourceserver.jwt.issuer-uri=${CSAF_OIDC_ISSUER_URL:http://localhost/realms/csaf} # templates -csaf.document.templates.file=${CSAF_TEMPLATES_FILE:} +csaf.document.templates.file=${CSAF_TEMPLATES_FILE:./templates/allTemplates.json} csaf.document.templates.companyLogoPath=${CSAF_COMPANY_LOGO_PATH:} # versioning @@ -34,7 +34,7 @@ csaf.document.versioning=${CSAF_VERSIONING:Semantic} csaf.summary.publication=${CSAF_SUMMARY_PUBLICATION:Initial Publication} # validation -csaf.validation.baseurl=${CSAF_VALIDATION_BASE_URL:} +csaf.validation.baseurl=${CSAF_VALIDATION_BASE_URL:http://localhost/validate/api/v1/} # max. levenshtein distance between changed values in the csaf document to decide whether a change is a patch or a minor change csaf.versioning.levenshtein=${CSAF_VERSIONING_LEVENSHTEIN:4} @@ -42,8 +42,14 @@ csaf.versioning.levenshtein=${CSAF_VERSIONING_LEVENSHTEIN:4} # generation of /document/tracking/id's # Base URL (of the server hosting the documents) -csaf.references.baseURL=${CSAF_REFERENCES_BASE_URL:} +csaf.references.baseURL=${CSAF_REFERENCES_BASE_URL:http://example.com} # Company code in the generated tracking id -csaf.trackingid.company=${CSAF_TRACKINGID_COMPANY:} +csaf.trackingid.company=${CSAF_TRACKINGID_COMPANY:ExampleInc} # Number of digits of the sequential number of the tracking id. Missing digits are filled with zeros -csaf.trackingid.digits=${CSAF_TRACKINGID_DIGITS:} \ No newline at end of file +csaf.trackingid.digits=${CSAF_TRACKINGID_DIGITS:} + +csaf.autoPublish.enabled=${CSAF_AUTOPUBLISH_ENABLED:true} +csaf.autoPublish.enableInsecureTLS=${CSAF_AUTOPUBLISH_INSECURETLS:true} +csaf.autoPublish.url=${CSAF_AUTOPUBLISH_URL:http://localhost/cgi-bin/csaf_provider.go/api/upload} +csaf.autoPublish.password=${CSAF_AUTOPUBLISH_PASSWORD:secretpassword} +csaf.autoPublish.cron=${CSAF_AUTOPUBLISH_CROM:0 * * * * *} \ No newline at end of file diff --git a/src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/exxcellent-2021AB123.json b/src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/example-2021AB123.json similarity index 68% rename from src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/exxcellent-2021AB123.json rename to src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/example-2021AB123.json index 28e8084c..860338e5 100644 --- a/src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/exxcellent-2021AB123.json +++ b/src/main/resources/de/bsi/secvisogram/csaf_cms_backend/json/example-2021AB123.json @@ -4,19 +4,19 @@ "csaf_version": "2.0", "publisher": { "category": "coordinator", - "name": "exccellent", - "namespace": "https://exccellent.de" + "name": "Example Inc.", + "namespace": "https://example.com" }, "title": "TestRSc", "tracking": { "current_release_date": "2022-01-11T11:00:00.000Z", - "id": "exxcellent-2021AB123", + "id": "example-2021AB123", "initial_release_date": "2022-01-12T11:00:00.000Z", "revision_history": [ { "date": "2022-01-12T11:00:00.000Z", "number": "0.0.1", - "summary": "Test rsvSummary" + "summary": "Test summary" } ], "status": "draft", @@ -32,15 +32,13 @@ "acknowledgments": [ { "names": [ - "Rainer", - "Gregor", - "Timo" + "Tom", + "Jerry" ], - "organization": "exxcellent contribute", - "summary": "Summary 1234", + "organization": "Example Inc.", + "summary": "Summary", "urls": [ - "https://exccellent.de", - "https:/heise.de" + "https://example.com" ] } ] diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java index 91250eb7..f5d36255 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryServiceTest.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ibm.icu.text.SimpleDateFormat; import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; import de.bsi.secvisogram.csaf_cms_backend.couchdb.*; @@ -44,7 +45,14 @@ import java.time.Instant; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -93,7 +101,13 @@ public class AdvisoryServiceTest { private static final String csafJson = """ { "document": { - "category": "CSAF_BASE" + "category": "CSAF_BASE", + "distribution": { + "tlp": { + "label": "WHITE" + } + }, + "id": "a-1" } }"""; @@ -645,6 +659,51 @@ public void changeAdvisoryWorkflowStateTest_RfPublication_invalidDoc() throws IO } } + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", + justification = "Bug in SpotBugs: https://github.com/spotbugs/spotbugs/issues/1338") + public void changeAdvisoryWorkflowStateTest_AutoPublish() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, null); + + assertEquals(WorkflowState.AutoPublish, advisoryService.getAdvisory(idRev.getId()).getWorkflowState()); + + } + } + + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", + justification = "Bug in SpotBugs: https://github.com/spotbugs/spotbugs/issues/1338") + public void changeAdvisoryWorkflowStateTest_AutoPublishWithTime() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJson)); + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'.000000000Z"); + String publishTime = sdf.format(new Date()); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, publishTime, null); + + assertEquals(WorkflowState.AutoPublish, advisoryService.getAdvisory(idRev.getId()).getWorkflowState()); + + } + } + @Test @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) @SuppressFBWarnings(value = "RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE", diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfigTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfigTest.java new file mode 100644 index 00000000..a3d9cd77 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishConfigTest.java @@ -0,0 +1,77 @@ +package de.bsi.secvisogram.csaf_cms_backend.task; + +import static org.junit.jupiter.api.Assertions.*; + +import de.bsi.secvisogram.csaf_cms_backend.config.CsafAutoPublishConfiguration; +import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.security.concurrent.DelegatingSecurityContextScheduledExecutorService; +import org.springframework.test.util.ReflectionTestUtils; + +public class PublishConfigTest { + + @Test + void taskBean_returnsPublishJob() { + PublishConfig cfg = new PublishConfig(); + assertNotNull(cfg.task()); + assertTrue(cfg.task() instanceof PublishJob); + } + + @Test + void taskExecutor_appliesSecurityContextToRunnable() throws Exception { + PublishConfig cfg = new PublishConfig(); + Executor ex = cfg.taskExecutor(); + assertNotNull(ex); + assertTrue(ex instanceof DelegatingSecurityContextScheduledExecutorService); + DelegatingSecurityContextScheduledExecutorService svc = (DelegatingSecurityContextScheduledExecutorService) ex; + + try { + Future f = svc.submit((Callable) () -> { + var ctx = org.springframework.security.core.context.SecurityContextHolder.getContext(); + var auth = ctx == null ? null : ctx.getAuthentication(); + return auth == null ? null : auth.getName(); + }); + + String name = f.get(5, TimeUnit.SECONDS); + // createSchedulerSecurityContext uses "PublisherTask" as principal name + assertEquals("PublisherTask", name); + } finally { + svc.shutdownNow(); + } + } + + @Test + void configureTasks_whenAutoPublishEnabled_callsRegistrarMethods() { + PublishConfig cfg = new PublishConfig(); + CsafConfiguration csaf = new CsafConfiguration(); + CsafAutoPublishConfiguration ap = new CsafAutoPublishConfiguration().setEnabled(true).setCron("*/5 * * * * *"); + csaf.setAutoPublish(ap); + ReflectionTestUtils.setField(cfg, "configuration", csaf); + + ScheduledTaskRegistrar registrar = Mockito.mock(ScheduledTaskRegistrar.class); + cfg.configureTasks(registrar); + + Mockito.verify(registrar).setScheduler(Mockito.any()); + Mockito.verify(registrar).addCronTask(Mockito.any(Runnable.class), Mockito.eq("*/5 * * * * *")); + } + + @Test + void configureTasks_whenAutoPublishNull_doesNotCallRegistrar() { + PublishConfig cfg = new PublishConfig(); + CsafConfiguration csaf = new CsafConfiguration(); + csaf.setAutoPublish(null); + ReflectionTestUtils.setField(cfg, "configuration", csaf); + + ScheduledTaskRegistrar registrar = Mockito.mock(ScheduledTaskRegistrar.class); + cfg.configureTasks(registrar); + + Mockito.verify(registrar, Mockito.never()).setScheduler(Mockito.any()); + Mockito.verify(registrar, Mockito.never()).addCronTask(Mockito.any(), Mockito.anyString()); + } +} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJobTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJobTest.java new file mode 100644 index 00000000..eed5cf3b --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/task/PublishJobTest.java @@ -0,0 +1,169 @@ +package de.bsi.secvisogram.csaf_cms_backend.task; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import de.bsi.secvisogram.csaf_cms_backend.config.CsafAutoPublishConfiguration; +import de.bsi.secvisogram.csaf_cms_backend.config.CsafConfiguration; +import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; +import de.bsi.secvisogram.csaf_cms_backend.model.ExportFormat; +import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryInformationResponse; +import de.bsi.secvisogram.csaf_cms_backend.service.AdvisoryService; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.http.HttpEntity; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import java.nio.charset.StandardCharsets; +import reactor.core.publisher.Mono; + +@SuppressWarnings({"unchecked"}) +public class PublishJobTest { + + @Test + void fromFile_containsCsafAndTlpParts() throws Exception { + PublishJob job = new PublishJob(); + Path tmp = Files.createTempFile("csaf-test", ".json"); + MultiValueMap> map = (MultiValueMap>) ReflectionTestUtils.invokeMethod(job, "fromFile", tmp, "TRACK1"); + assertNotNull(map); + assertTrue(map.containsKey("csaf")); + assertTrue(map.containsKey("tlp")); + } + + @Test + void createWebClient_and_getAuthenticationCode_and_publishJob_happyPath() throws Exception { + // prepare PublishJob with mocked AdvisoryService and configuration + PublishJob job = new PublishJob(); + AdvisoryService advisoryService = mock(AdvisoryService.class); + CsafConfiguration cfg = new CsafConfiguration(); + CsafAutoPublishConfiguration ap = new CsafAutoPublishConfiguration().setEnableInsecureTLS(false) + .setUrl("http://localhost") + .setPassword("secret") + .setEnabled(true); + cfg.setAutoPublish(ap); + ReflectionTestUtils.setField(job, "advisoryService", advisoryService); + ReflectionTestUtils.setField(job, "configuration", cfg); + + // prepare advisory to publish + AdvisoryInformationResponse adv = new AdvisoryInformationResponse("adv-1", WorkflowState.AutoPublish); + adv.setCurrentReleaseDate(DateTimeFormatter.ISO_INSTANT.format(Instant.now().minusSeconds(60))); + adv.setAdvisoryId("adv-1"); + adv.setDocumentTrackingId("TRACK1"); + adv.setRevision("rev-1"); + + when(advisoryService.getAdvisoryInformations("")).thenReturn(List.of(adv)); + + Path tmp = Files.createTempFile("adv", ".json"); + when(advisoryService.exportAdvisory("adv-1", ExportFormat.JSON)).thenReturn(tmp); + + // mock WebClient static builder to return a mock chain that returns Mono.just("ok") + WebClient mockWebClient = mock(WebClient.class); + WebClient.RequestBodyUriSpec uriSpec = mock(WebClient.RequestBodyUriSpec.class); + WebClient.RequestBodySpec bodySpec = mock(WebClient.RequestBodySpec.class); + WebClient.RequestHeadersSpec headersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec respSpec = mock(WebClient.ResponseSpec.class); + + when(mockWebClient.post()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(bodySpec); + when(bodySpec.contentType(any(MediaType.class))).thenReturn(bodySpec); + when(bodySpec.header(anyString(), anyString())).thenReturn(bodySpec); + when(bodySpec.body(any(BodyInserters.FormInserter.class))).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(respSpec); + when(respSpec.bodyToMono(String.class)).thenReturn(Mono.just("ok")); + + WebClient.Builder builder = mock(WebClient.Builder.class); + when(builder.clientConnector(any())).thenReturn(builder); + when(builder.build()).thenReturn(mockWebClient); + + try (MockedStatic ws = Mockito.mockStatic(WebClient.class)) { + ws.when(WebClient::builder).thenReturn(builder); + + // call publishJob - should exercise createWebClient via the mocked builder + job.publishJob(); + } + + // verify that workflow state change was requested + verify(advisoryService).changeAdvisoryWorkflowState(eq("adv-1"), eq("rev-1"), eq(WorkflowState.Published), anyString(), eq(DocumentTrackingStatus.Final)); + + // getAuthenticationCode: ensure encoded password matches + String encoded = (String) ReflectionTestUtils.invokeMethod(job, "getAuthenticationCode"); + assertNotNull(encoded); + org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder encoder = new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder(); + assertTrue(encoder.matches("secret", encoded)); + } + + @Test + void run_swallowsExceptions() throws Exception { + PublishJob job = spy(new PublishJob()); + doThrow(new CsafException("fail", null)).when(job).publishJob(); + // should not throw + job.run(); + } + + @Test + void publishJob_whenWebClientReturnsError_skipsWorkflowChange() throws Exception { + PublishJob job = new PublishJob(); + AdvisoryService advisoryService = mock(AdvisoryService.class); + CsafConfiguration cfg = new CsafConfiguration(); + CsafAutoPublishConfiguration ap = new CsafAutoPublishConfiguration().setEnableInsecureTLS(false) + .setUrl("http://localhost") + .setPassword("secret") + .setEnabled(true); + cfg.setAutoPublish(ap); + ReflectionTestUtils.setField(job, "advisoryService", advisoryService); + ReflectionTestUtils.setField(job, "configuration", cfg); + + AdvisoryInformationResponse adv = new AdvisoryInformationResponse("adv-2", WorkflowState.AutoPublish); + adv.setCurrentReleaseDate(DateTimeFormatter.ISO_INSTANT.format(Instant.now().minusSeconds(60))); + adv.setAdvisoryId("adv-2"); + adv.setDocumentTrackingId("TRACK2"); + adv.setRevision("rev-2"); + + when(advisoryService.getAdvisoryInformations("")) + .thenReturn(List.of(adv)); + + Path tmp = Files.createTempFile("adv2", ".json"); + when(advisoryService.exportAdvisory("adv-2", ExportFormat.JSON)).thenReturn(tmp); + + // prepare failing WebClient chain + WebClient mockWebClient = mock(WebClient.class); + WebClient.RequestBodyUriSpec uriSpec = mock(WebClient.RequestBodyUriSpec.class); + WebClient.RequestBodySpec bodySpec = mock(WebClient.RequestBodySpec.class); + WebClient.RequestHeadersSpec headersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec respSpec = mock(WebClient.ResponseSpec.class); + + when(mockWebClient.post()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(bodySpec); + when(bodySpec.contentType(any(MediaType.class))).thenReturn(bodySpec); + when(bodySpec.header(anyString(), anyString())).thenReturn(bodySpec); + when(bodySpec.body(any(BodyInserters.FormInserter.class))).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(respSpec); + when(respSpec.bodyToMono(String.class)).thenReturn(Mono.error( + WebClientResponseException.create(500, "err", null, "errbody".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8) + )); + + WebClient.Builder builder = mock(WebClient.Builder.class); + when(builder.clientConnector(any())).thenReturn(builder); + when(builder.build()).thenReturn(mockWebClient); + + try (MockedStatic ws = Mockito.mockStatic(WebClient.class)) { + ws.when(WebClient::builder).thenReturn(builder); + job.publishJob(); + } + + // verify that changeAdvisoryWorkflowState was NOT called due to PublisherException + verify(advisoryService, never()).changeAdvisoryWorkflowState(anyString(), anyString(), any(), anyString(), any()); + } +} diff --git a/templates/allTemplates.json b/templates/allTemplates.json new file mode 100644 index 00000000..b45bd1d6 --- /dev/null +++ b/templates/allTemplates.json @@ -0,0 +1,10 @@ +[ + { "id": "min", + "description":"Minimal template", + "file":"doc-min.json" + }, + { "id": "max", + "description":"Maximal template", + "file":"doc-max.json" + } +] \ No newline at end of file diff --git a/templates/doc-max.json b/templates/doc-max.json new file mode 100644 index 00000000..d9c8eec5 --- /dev/null +++ b/templates/doc-max.json @@ -0,0 +1,334 @@ +{ + "document": { + "acknowledgments": [ + { + "names": [""], + "organization": "", + "summary": "", + "urls": [""] + } + ], + "aggregate_severity": { + "namespace": "", + "text": "" + }, + "category": "", + "csaf_version": "2.0", + "distribution": { + "text": "", + "tlp": { + "label": "", + "url": "https://www.first.org/tlp/" + } + }, + "lang": "", + "notes": [ + { + "audience": "", + "category": "", + "text": "", + "title": "" + } + ], + "publisher": { + "category": "", + "contact_details": "", + "issuing_authority": "", + "name": "", + "namespace": "" + }, + "references": [ + { + "category": "external", + "summary": "", + "url": "" + } + ], + "source_lang": "", + "title": "", + "tracking": { + "aliases": [""], + "current_release_date": "", + "generator": { + "date": "", + "engine": { + "name": "", + "version": "" + } + }, + "id": "", + "initial_release_date": "", + "revision_history": [ + { + "date": "", + "legacy_version": "", + "number": "", + "summary": "" + } + ], + "status": "", + "version": "" + } + }, + "product_tree": { + "branches": [ + { + "branches": [], + "category": "", + "name": "", + "product": { + "name": "", + "product_id": "", + "product_identification_helper": { + "cpe": "", + "hashes": [ + { + "file_hashes": [ + { + "algorithm": "sha256", + "value": "" + } + ], + "filename": "" + } + ], + "model_numbers": [""], + "purl": "", + "sbom_urls": [""], + "serial_numbers": [""], + "skus": [""], + "x_generic_uris": [ + { + "namespace": "", + "uri": "" + } + ] + } + } + } + ], + "full_product_names": [ + { + "name": "", + "product_id": "", + "product_identification_helper": { + "cpe": "", + "hashes": [ + { + "file_hashes": [ + { + "algorithm": "sha256", + "value": "" + } + ], + "filename": "" + } + ], + "model_numbers": [""], + "purl": "", + "sbom_urls": [""], + "serial_numbers": [""], + "skus": [""], + "x_generic_uris": [ + { + "namespace": "", + "uri": "" + } + ] + } + } + ], + "product_groups": [ + { + "group_id": "", + "product_ids": ["", ""], + "summary": "" + } + ], + "relationships": [ + { + "category": "", + "full_product_name": { + "name": "", + "product_id": "", + "product_identification_helper": { + "cpe": "", + "hashes": [ + { + "file_hashes": [ + { + "algorithm": "sha256", + "value": "" + } + ], + "filename": "" + } + ], + "model_numbers": [""], + "purl": "", + "sbom_urls": [""], + "serial_numbers": [""], + "skus": [""], + "x_generic_uris": [ + { + "namespace": "", + "uri": "" + } + ] + } + }, + "product_reference": "", + "relates_to_product_reference": "" + } + ] + }, + "vulnerabilities": [ + { + "acknowledgments": [ + { + "names": [""], + "organization": "", + "summary": "", + "urls": [""] + } + ], + "cve": "", + "cwe": { + "id": "", + "name": "" + }, + "discovery_date": "", + "flags": [ + { + "date": "", + "group_ids": [""], + "label": "", + "product_ids": [""] + } + ], + "ids": [ + { + "system_name": "", + "text": "" + } + ], + "involvements": [ + { + "date": "", + "party": "", + "status": "", + "summary": "" + } + ], + "notes": [ + { + "audience": "", + "category": "", + "text": "", + "title": "" + } + ], + "product_status": { + "first_affected": [""], + "first_fixed": [""], + "fixed": [""], + "known_affected": [""], + "known_not_affected": [""], + "last_affected": [""], + "recommended": [""], + "under_investigation": [""] + }, + "references": [ + { + "category": "external", + "summary": "", + "url": "" + } + ], + "release_date": "", + "remediations": [ + { + "category": "", + "date": "", + "details": "", + "entitlements": [""], + "group_ids": [""], + "product_ids": [""], + "restart_required": { + "category": "", + "details": "" + }, + "url": "" + } + ], + "scores": [ + { + "cvss_v2": { + "accessComplexity": "", + "accessVector": "", + "authentication": "", + "availabilityImpact": "", + "availabilityRequirement": "", + "baseScore": "", + "collateralDamagePotential": "", + "confidentialityImpact": "", + "confidentialityRequirement": "", + "environmentalScore": "", + "exploitability": "", + "integrityImpact": "", + "integrityRequirement": "", + "remediationLevel": "", + "reportConfidence": "", + "targetDistribution": "", + "temporalScore": "", + "vectorString": "", + "version": "" + }, + "cvss_v3": { + "attackComplexity": "", + "attackVector": "", + "availabilityImpact": "", + "availabilityRequirement": "", + "baseScore": "", + "baseSeverity": "", + "confidentialityImpact": "", + "confidentialityRequirement": "", + "environmentalScore": "", + "environmentalSeverity": "", + "exploitCodeMaturity": "", + "integrityImpact": "", + "integrityRequirement": "", + "modifiedAttackComplexity": "", + "modifiedAttackVector": "", + "modifiedAvailabilityImpact": "", + "modifiedConfidentialityImpact": "", + "modifiedIntegrityImpact": "", + "modifiedPrivilegesRequired": "", + "modifiedScope": "", + "modifiedUserInteraction": "", + "privilegesRequired": "", + "remediationLevel": "", + "reportConfidence": "", + "scope": "", + "temporalScore": "", + "temporalSeverity": "", + "userInteraction": "", + "vectorString": "", + "version": "" + }, + "products": [""] + } + ], + "threats": [ + { + "category": "", + "date": "", + "details": "", + "group_ids": [""], + "product_ids": [""] + } + ], + "title": "" + } + ] +} diff --git a/templates/doc-min.json b/templates/doc-min.json new file mode 100644 index 00000000..ce3b2397 --- /dev/null +++ b/templates/doc-min.json @@ -0,0 +1,6 @@ +{ + "document": { + "category": "csaf_security_advisory", + "csaf_version": "2.0" + } +}