UNCLASSIFIED

Commit 0ed7a8ca authored by gavin.scallon's avatar gavin.scallon
Browse files

Revert "Merge branch 'report-creation' into 'master'"

This reverts merge request !3
parent e610805e
stages:
- format
- lint
shellcheck:
stage: lint
image: registry.dsop.io/ironbank-tools/ironbank-pipeline/shellcheck
# TODO: remove allow_failure
allow_failure: true
script:
- |
set -o pipefail
set +e # remove gitlab ci setting
shopt -s nullglob
files=$(echo stages/*/*.sh)
if [ -n "$files" ]
then
shellcheck --exclude=SC2153 --format=gcc -- $files
ret=$?
fi
echo "# Scanning embedded scripts..."
ret=0
IFS=$'\n' # Minor bug: newlines in filenames will still be processed incorrectly
for file in $(find . -name '*.yaml' -o -name '*.yml')
do
echo "# $file"
yq -r '.[] | objects | .before_script, .script, .after_script | select(. != null) | join("\n")' "$file" | shellcheck --exclude=SC2153 --format=gcc -s bash -
yq_ret=$?
if [ $yq_ret -ne 0 ]
then
ret=$yq_ret
fi
done
exit "$ret"
black:
stage: format
image: "${GITLAB_INTERNAL_REGISTRY}/ironbank-tools/ironbank-pipeline/python:pyyaml"
# TODO: remove allow_failure
script:
- pip install git+git://github.com/psf/black
- black --check --diff --color -t py36 .
pylama:
stage: lint
image: "${GITLAB_INTERNAL_REGISTRY}/ironbank-tools/ironbank-pipeline/python:pyyaml"
# TODO: remove allow_failure
allow_failure: true
dependencies:
- black
script:
- pip install pylama
- pylama
## ignore line errors as blacks has been ran against code.
prettier:
stage: format
image: "${GITLAB_INTERNAL_REGISTRY}/ironbank-tools/ironbank-pipeline/all-in-one-fedora:1.0"
script:
- curl -sL https://rpm.nodesource.com/setup_12.x | bash -
- dnf install -y nodejs
- npx prettier -c .
shfmt:
stage: format
image: "${GITLAB_INTERNAL_REGISTRY}/ironbank-tools/ironbank-pipeline/all-in-one-fedora:1.0"
script:
- dnf install wget -y
- wget https://github.com/mvdan/sh/releases/download/v3.1.2/shfmt_v3.1.2_linux_amd64
- chmod a+x shfmt_v3.1.2_linux_amd64
- ./shfmt_v3.1.2_linux_amd64 -d .
......@@ -49,7 +49,7 @@
"plugin-name": {
"description": "Name of the Iron Bank container",
"type": "string",
"$ref": "#/definitions/printable-characters-without-newlines-or-slashes"
"$ref": "#/definitions/printable-characters-without-newlines"
},
"version": {
"description": "Version of the plugin",
......
FROM scratch
COPY . .
USER 1001
HEALTHCHECK NONE
......@@ -14,5 +14,6 @@ build:
when: always
paths:
- "${ARTIFACT_DIR}/"
expire_in: 1 week
reports:
dotenv: "${ARTIFACT_DIR}/build.env"
......@@ -9,9 +9,22 @@ harbor_plugin_path="${REGISTRY1_PLUGINS_URL}/${project_name}:${PLUGIN_VERSION}"
mkdir "${ARTIFACT_DIR}"
echo "HARBOR_PLUGIN_PATH=${harbor_plugin_path}" >>"${ARTIFACT_DIR}/build.env"
# If the project does not include a Dockerfile, use a scratch Dockerfile from the build stage directory
if [[ "${BUILD_FROM_SOURCE}" == "1" ]]; then
build_directory="${PIPELINE_REPO_DIR}"
DOCKERFILE_LOCATION="${PIPELINE_REPO_DIR}"
else
build_directory="./scratch_build"
mkdir "${build_directory}"
DOCKERFILE_LOCATION="${PIPELINE_REPO_DIR}/stages/build/Dockerfile"
cp "${DOCKERFILE_LOCATION}" "${build_directory}"
fi
echo "DOCKERFILE_LOCATION=${DOCKERFILE_LOCATION}" >>"${ARTIFACT_DIR}/build.env"
# Load HTTP and S3 external resources
for file in "${ARTIFACT_STORAGE}"/import-artifacts/external-resources/*; do
cp -v "$file" .
cp -v "$file" "${build_directory}"
done
echo "${DOCKER_AUTH_CONFIG_PULL}" | base64 -d >>/tmp/prod_auth.json
......@@ -27,7 +40,7 @@ buildah bud \
--loglevel=3 \
--storage-driver=vfs \
-t "${harbor_plugin_path}" \
.
"${build_directory}"
echo "${DOCKER_AUTH_CONFIG_PLUGINS}" | base64 -d >>plugins_auth.json
......
......@@ -13,5 +13,6 @@ import artifacts:
when: always
paths:
- "${ARTIFACT_DIR}/"
expire_in: 1 week
reports:
dotenv: artifact.env
......@@ -13,6 +13,7 @@ folder structure:
when: always
paths:
- "${ARTIFACT_DIR}/"
expire_in: 1 week
reports:
dotenv: "${ARTIFACT_DIR}/build_source.env"
......@@ -29,5 +30,6 @@ plugins manifest:
when: always
paths:
- "${ARTIFACT_DIR}/"
expire_in: 1 week
reports:
dotenv: "${ARTIFACT_DIR}/variables.env"
......@@ -10,7 +10,10 @@ if ! [[ -f plugins_manifest.yaml ]]; then
echo "plugins_manifest.yaml not found"
exit 1
fi
if ! [[ -f Dockerfile ]]; then
echo "Dockerfile not found"
exit 1
mkdir -p "${ARTIFACT_DIR}"
touch "${ARTIFACT_DIR}/build_source.env"
if [[ -f Dockerfile ]]; then
echo 'BUILD_FROM_SOURCE="1"' >>"${ARTIFACT_DIR}/build_source.env"
fi
.publish:
stage: publish
# todo: uncomment for prod
# only:
# - master
tags:
- ironbank-plugins
include:
......
#!/usr/bin/python3
import sys
import json
import os
import shlex
import subprocess
import boto3
import logging
from botocore.exceptions import ClientError
import argparse
import logging
def get_repomap(object_name, bucket="ironbank-pipeline-artifacts"):
access_key = os.environ["S3_ACCESS_KEY"]
secret_key = os.environ["S3_SECRET_KEY"]
s3_client = boto3.client(
"s3",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name="us-gov-west-1",
)
print(object_name)
try:
response = s3_client.download_file(bucket, object_name, "repo_map.json")
except ClientError as e:
logging.error(e)
print("Existing repo_map.json not found, creating new repo_map.json")
return False
return True
def source_keywords(keywords_file):
keywords_file = ""
num_keywords = 0
with open(keywords_file) as f:
for num_keywords, l in enumerate(f):
pass
num_keywords += 1
print("Number of keywords detected: ", num_keywords)
keywords_keys, keywords_list = [], []
x = 0
while x < num_keywords:
keywords_keys.append("keyword")
x += 1
with open(keywords_file, mode="r", encoding="utf-8") as kf:
for line in kf:
keyword_entry = line.strip()
keywords_list.append(keyword_entry)
output_list = json.dumps({"keywords": keywords_list})
return output_list
def main():
# Get logging level, set manually when running pipeline
loglevel = os.environ.get("LOGLEVEL", "INFO").upper()
if loglevel == "DEBUG":
logging.basicConfig(
level=loglevel,
format="%(levelname)s [%(filename)s:%(lineno)d]: %(message)s",
)
logging.debug("Log level set to debug")
else:
logging.basicConfig(level=loglevel, format="%(levelname)s: %(message)s")
logging.info("Log level set to info")
parser = argparse.ArgumentParser(description="Downloads target from s3")
parser.add_argument("--target", help="File to upload")
args = parser.parse_args()
object_name = args.target
existing_repomap = get_repomap(object_name)
source_keywords("${ARTIFACT_STORAGE}/preflight/keywords.txt")
new_data = {
os.environ.get("build_number"): {
"Plugin_Name": os.environ.get("PLUGIN_NAME"),
"Plugin_Version": os.environ.get("PLUGIN_VERSION"),
"Project_Readme": os.environ.get("project_readme"),
"Anchore_Security_Results": os.environ.get("anchore_security_results"),
"Tar_Name": os.environ.get("tar_name"),
"Tar_Location": os.environ.get("tar_location"),
"Full_Report": os.environ.get("full_report"),
"Repo_Name": os.environ.get("repo_name"),
"Keywords": output_list,
}
}
if existing_repomap:
with open("repo_map.json") as f:
data = json.load(f)
data.update(new_data)
with open("repo_map.json", "w") as f:
json.dump(data, f, indent=4)
else:
with open("repo_map.json", "w") as outfile:
json.dump(new_data, outfile, indent=4, sort_keys=True)
if __name__ == "__main__":
sys.exit(main())
#!/bin/bash
S3_HTML_LINK="https://s3-us-gov-west-1.amazonaws.com/${S3_REPORT_BUCKET}/${BASE_BUCKET_DIRECTORY}/${PLUGIN_NAME}/${PLUGIN_VERSION}"
directory_date=$(date --utc '+%FT%T.%3N')
export REMOTE_DOCUMENTATION_DIRECTORY="${directory_date}_${CI_PIPELINE_ID}"
export REMOTE_REPORT_DIRECTORY="${REMOTE_DOCUMENTATION_DIRECTORY}/reports"
export repo_name="${CI_PROJECT_NAME}"
export plugin_name="${PLUGIN_NAME}"
export plugin_tag="${PLUGIN_VERSION}"
export plugin_path="${REGISTRY_URL}/${PLUGIN_NAME}:${PLUGIN_VERSION}"
export plugin_url="${S3_HTML_LINK}/${REMOTE_REPORT_DIRECTORY}/${PLUGIN_NAME}_plugin_scan_result.tar.gz"
export tar_location="${S3_HTML_LINK}/${REMOTE_REPORT_DIRECTORY}/${REPORT_TAR_NAME}"
export anchore_security_results="${S3_HTML_LINK}/${REMOTE_REPORT_DIRECTORY}/csvs/anchore_security.csv"
# export output_dir="${ARTIFACT_DIR}"
export project_readme="${S3_HTML_LINK}/${REMOTE_REPORT_DIRECTORY}/${PROJECT_README}"
#!/bin/bash
set -Eeuo pipefail
# TODO: Remove
if echo "${CI_PROJECT_DIR}" | grep -q -F 'pipeline-test-project' && [ "${CI_COMMIT_BRANCH}" == "master" ]; then
echo "Skipping publish. Cannot publish when working with pipeline test projects master branch..."
exit 0
fi
mkdir -p "${ARTIFACT_DIR}"
# pip install boto3 ushlex
# TODO: Change the base bucket directroy to use prod s3 if on master
# if [ "${CI_COMMIT_BRANCH}" == "master" ]; then
# BASE_BUCKET_DIRECTORY="plugins-scan-reports"
# fi
if [ "${CI_COMMIT_BRANCH}" == "master" ]; then
BASE_BUCKET_DIRECTORY="container-scan-reports"
fi
IMAGE_PATH=$(echo "${CI_PROJECT_PATH}" | sed -e 's/.*dsop\/\(.*\)/\1/')
# Files are guaranteed to exist by the preflight checks
PROJECT_README="README.md"
PROJECT_LICENSE="LICENSE"
source "${PIPELINE_REPO_DIR}"/stages/publish/plugin_repo_map_vars.sh
source "${PIPELINE_REPO_DIR}"/stages/publish/repo_map_vars.sh
python3 "${PIPELINE_REPO_DIR}"/stages/publish/create_repo_map.py --target ${BASE_BUCKET_DIRECTORY}/"${IMAGE_PATH}"/repo_map.json
if [[ "${DISTROLESS:-}" ]]; then
python3 "${PIPELINE_REPO_DIR}"/stages/publish/create_repo_map_other.py --target ${BASE_BUCKET_DIRECTORY}/"${IMAGE_PATH}"/repo_map.json
else
python3 "${PIPELINE_REPO_DIR}"/stages/publish/create_repo_map_default.py --target ${BASE_BUCKET_DIRECTORY}/"${IMAGE_PATH}"/repo_map.json
fi
mkdir reports
......@@ -27,6 +31,7 @@ cp -r "${DOCUMENTATION_DIRECTORY}"/reports/* reports/
cp -r "${SCAN_DIRECTORY}"/* reports/
cp "${BUILD_DIRECTORY}"/"${CI_PROJECT_NAME}"-"${IMAGE_VERSION}".tar reports/"${CI_PROJECT_NAME}"-"${IMAGE_VERSION}".tar
cp "${PROJECT_LICENSE}" "${PROJECT_README}" reports/
# Debug
ls reports
......@@ -46,4 +51,5 @@ for file in $(find "${SCAN_DIRECTORY}" -name "*" -type f); do
done
python3 "${PIPELINE_REPO_DIR}/stages/publish/s3_upload.py" --file "${PROJECT_README}" --bucket "${S3_REPORT_BUCKET}" --dest "${BASE_BUCKET_DIRECTORY}/${IMAGE_PATH}/${IMAGE_VERSION}/${REMOTE_REPORT_DIRECTORY}/${PROJECT_README}"
python3 "${PIPELINE_REPO_DIR}/stages/publish/s3_upload.py" --file "${PROJECT_LICENSE}" --bucket "${S3_REPORT_BUCKET}" --dest "${BASE_BUCKET_DIRECTORY}/${IMAGE_PATH}/${IMAGE_VERSION}/${REMOTE_REPORT_DIRECTORY}/${PROJECT_LICENSE}"
python3 "${PIPELINE_REPO_DIR}/stages/publish/s3_upload.py" --file "${REPORT_TAR_NAME}" --bucket "${S3_REPORT_BUCKET}" --dest "${BASE_BUCKET_DIRECTORY}/${IMAGE_PATH}/${IMAGE_VERSION}/${REMOTE_REPORT_DIRECTORY}/${REPORT_TAR_NAME}-${IMAGE_VERSION}-reports-signature.tar.gz"
upload to s3:
extends: .publish
resource_group: s3_phase
only:
- master
- development
variables:
#TODO: Put these in globals
IMAGE_FILE: "${CI_PROJECT_NAME}-${IMAGE_VERSION}"
SCAN_DIRECTORY: "${ARTIFACT_STORAGE}/scan-results"
DOCUMENTATION_DIRECTORY: "${ARTIFACT_STORAGE}/documentation"
BUILD_DIRECTORY: "${ARTIFACT_STORAGE}/build"
BASE_BUCKET_DIRECTORY: testing/plugins-scan-reports
SIG_FILE: signature
BASE_BUCKET_DIRECTORY: testing/container-scan-reports
DOCUMENTATION_FILENAME: documentation
ARTIFACT_DIR: ${ARTIFACT_STORAGE}/documentation
REPORT_TAR_NAME: ${CI_PROJECT_NAME}-${IMAGE_VERSION}-reports-signature.tar.gz
dependencies:
- load scripts
- build
- sign image
- sign manifest
- write json documentation
- anchore scan
- report generation
- plugins manifest
- twistlock scan
- openscap cve
- openscap compliance
- csv output
- hardening_manifest
- wl compare lint
script:
- '"${PIPELINE_REPO_DIR}/stages/publish/upload-to-s3-run.sh"'
......@@ -15,3 +15,4 @@ report generation:
when: always
paths:
- "${REPORT_PATH}"
expire_in: 1 week
......@@ -5,7 +5,7 @@ from bs4 import BeautifulSoup
import re
import json
import os
import pandas as pd
import pandas
import argparse
import logging
import pathlib
......@@ -32,7 +32,16 @@ def main():
parser.add_argument(
"--anchore-sec", help="location of the anchore_security.json scan file"
)
parser.add_argument("--content-dir", help="Path to Anchore content JSON files.")
parser.add_argument("--binary-content", help="binary.json file path")
parser.add_argument("--files-content", help="files.json file path")
parser.add_argument("--gem-content", help="gem.json file path")
parser.add_argument("--go-content", help="go.json file path")
parser.add_argument("--java-content", help="java.json file path")
parser.add_argument("--malware-content", help="malware.json file path")
parser.add_argument("--npm-content", help="npm.json file path")
parser.add_argument("--nuget-content", help="nuget.json file path")
parser.add_argument("--os-content", help="os.json file path")
parser.add_argument("--python-content", help="python.json file path")
parser.add_argument(
"-o",
"--output-dir",
......@@ -51,9 +60,21 @@ def main():
if args.anchore_sec:
anc_sec_count = generate_anchore_sec_report(args.anchore_sec)
content_files = os.listdir(args.content_dir)
content_file_names = create_content_csvs(args.content_dir, content_files)
content_files = [
args.binary_content,
args.files_content,
args.gem_content,
args.go_content,
args.java_content,
args.malware_content,
args.npm_content,
args.npm_content,
args.nuget_content,
args.os_content,
args.python_content,
]
content_file_names = create_content_csvs(content_files)
generate_summary_report(
anc_sec_count,
......@@ -61,25 +82,29 @@ def main():
convert_to_excel(content_file_names)
# GENERATES ALL OF THE REPORTS FOR ALL OF THE THINGS INCLUDING A SUMMARY INTO /tmp/csvs/
def generate_all_reports(anchore_sec):
anc_sec_count = generate_anchore_sec_report(anchore_sec)
generate_summary_report(
anc_sec_count,
)
convert_to_excel()
# convert to Excel file
def convert_to_excel(list_of_content_reports):
read_sum = pd.read_csv(csv_dir + "summary.csv")
read_security = pd.read_csv(csv_dir + "anchore_security.csv")
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
csv_dir + "all_scans.xlsx"
) as writer:
read_sum = pandas.read_csv(csv_dir + "summary.csv")
read_security = pandas.read_csv(csv_dir + "anchore_security.csv")
with pandas.ExcelWriter(csv_dir + "all_scans.xlsx") as writer:
read_sum.to_excel(writer, sheet_name="Summary", header=False, index=False)
read_security.to_excel(
writer, sheet_name="Anchore CVE Results", header=False, index=False
)
for report in list_of_content_reports:
read_report = pd.read_csv(report["csv_file_path"])
read_report.to_excel(
writer,
sheet_name=f"{report['report_type']}-content",
header=True,
index=False,
)
read_report = pandas.read_csv(csv_dir + report)
read_report.to_excel(writer, sheet_name=report, header=False, index=False)
writer.save()
......@@ -104,6 +129,8 @@ def generate_summary_report(asf):
)
csv_writer.writerow("")
# sha_str = "Scans performed on container layer sha256:" + agf[1] + ",,,"
# csv_writer.writerow([sha_str])
sum_data.close()
......@@ -163,43 +190,47 @@ def get_anchore_full(anchore_file):
return cves
def create_content_csvs(content_dir, report_list):
return [write_content_csv(content_dir, report) for report in report_list]
def create_content_csvs(report_list):
file_names = []
for report in report_list:
report_content = read_content_csv(report)
file_name = write_content_csv(report_content)
file_names.append(file_name)
return file_name
def read_content_csv(filepath):
file = pathlib.Path(filepath)
try:
logging.debug(f"Reading file: {filepath}")
logging.debug(f"")
with file.open(mode="r") as f:
data = json.load(f)
return data
except ValueError:
logging.exception(f"Error reading file: {filepath}")
except ValueError as e:
logging.error(f"Error reading file: {filepath}")
raise e
sys.exit(1)
def write_content_csv(content_dir, filename):
report_data = read_content_csv(f"{content_dir}{filename}")
report_type = report_data["content_type"]
logging.debug(f"Creating {report_type} CSV.")
output_file_name = pathlib.Path(csv_dir, f"{report_type}.csv")
if len(report_data["content"]) < 1:
logging.debug(f"{report_type} returned no content data.")
fields = ["Content"]
content = [{"Content": f"No content returned for report: {report_type}"}]
def write_content_csv(report_data):
logging.debug(f"Creating {report_data['content_type']} CSV.")
if len(report_data["content"][0]) < 1:
logging.debug(f"{report_data['content_type']} returned no content data.")
fields = list()
else:
fields = list(report_data["content"][0].keys())
content = report_data["content"]
output_file_name = pathlib.Path(f"{csv_dir}{report_data['content_type']}.csv")
try:
with output_file_name.open(mode="w") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(content)
except Exception:
logging.exception(f"Error writing file: {output_file_name}")
writer.writerows(report_data["content"])
except Exception as e:
logging.error(f"Error writing file: {output_file_name}")
raise e
sys.exit(1)
return {"csv_file_path": str(output_file_name), "report_type": report_type}
return output_file_name
class AnchoreGate:
......
......@@ -5,5 +5,14 @@ mkdir -p "${REPORT_PATH}"
python3 "${PIPELINE_REPO_DIR}/stages/report-generation/pipeline_csv_gen.py" \
--anchore-sec "${ARTIFACT_STORAGE}/scan-results/anchore/anchore_security.json" \
--content-dir "${ARTIFACT_STORAGE}/scan-results/anchore/content_reports/" \
--os-content "${ARTIFACT_STORAGE}/scan-results/anchore/os.json" \
--files-content "${ARTIFACT_STORAGE}/scan-results/anchore/files.json" \
--malware-content "${ARTIFACT_STORAGE}/scan-results/anchore/malware.json" \
--binary-content "${ARTIFACT_STORAGE}/scan-results/anchore/binary.json" \
--gem-content "${ARTIFACT_STORAGE}/scan-results/anchore/gem.json" \
--go-content "${ARTIFACT_STORAGE}/scan-results/anchore/go.json" \
--java-content "${ARTIFACT_STORAGE}/scan-results/anchore/java.json" \
--npm-content "${ARTIFACT_STORAGE}/scan-results/anchore/npm.json" \
--nuget-content "${ARTIFACT_STORAGE}/scan-results/anchore/nuget.json" \
--python-content "${ARTIFACT_STORAGE}/scan-results/anchore/python.json" \
--output-dir "${REPORT_PATH}"/
......@@ -9,10 +9,11 @@ export ANCHORE_CLI_USER="${ANCHORE_USERNAME}"
export ANCHORE_CLI_PASS="${ANCHORE_PASSWORD}"
export ANCHORE_DEBUG="${ANCHORE_DEBUG}"
export ANCHORE_SCAN_DIRECTORY="${ANCHORE_SCANS}"
export IMAGE_NAME="${HARBOR_PLUGIN_PATH}"
# Add the image to Anchore along with it's Dockerfile. Use the `--force` flag to force
# a reanalysis of the image on pipeline reruns where the digest has not changed.
anchore-cli image add --noautosubscribe --dockerfile ./Dockerfile --force "${HARBOR_PLUGIN_PATH}"
anchore-cli image wait --timeout "${ANCHORE_TIMEOUT}" "${HARBOR_PLUGIN_PATH}"
anchore-cli image add --noautosubscribe --dockerfile "${DOCKERFILE_LOCATION}" --force "${IMAGE_NAME}"
anchore-cli image wait --timeout "${ANCHORE_TIMEOUT}" "${IMAGE_NAME}"
python3 "${PIPELINE_REPO_DIR}/stages/scanning/anchore_scan.py"
......@@ -191,7 +191,7 @@ class Anchore:
returned_contents = json.loads(image_contents.stdout)
filename = pathlib.Path(artifacts_path, "content_reports", f"{item}.json")
filename = pathlib.Path(artifacts_path, f"{item}.json")
with filename.open(mode="w") as f:
json.dump(returned_contents, f)
......
......@@ -15,7 +15,7 @@
# ANCHORE_CLI_USER=<user> \
# ANCHORE_SCAN_DIRECTORY=<outdir> \
# ANCHORE_DEBUG=True \
# PLUGIN_NAME=registry1.dsop.io/ironbank-staging/opensource/pipeline-test-project/kubectl:v1.18.8 \
# IMAGE_NAME=registry1.dsop.io/ironbank-staging/opensource/pipeline-test-project/kubectl:v1.18.8 \
# IMAGE_ID=<id> python3 anchore_scan.py
#
......@@ -36,3 +36,4 @@ anchore scan:
when: always
paths:
- "${ANCHORE_SCANS}/"
expire_in: 1 week
......@@ -32,7 +32,6 @@ def main():
# Create the directory if it does not exist
pathlib.Path(artifacts_path).mkdir(parents=True, exist_ok=True)
pathlib.Path(artifacts_path, "content_reports").mkdir(parents=True, exist_ok=True)
image = os.environ["IMAGE_FULLTAG"]
digest = anchore_scan.image_add(image)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment