From 30e6e5519d7ea711173d5778b33202984b7c3232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Wed, 3 Jul 2024 21:52:02 +0200 Subject: [PATCH 01/16] add basic Taskfile to help development the Make file is not dependent from the real source files and would be tedious work to add all the funktionality of the Taskfile to the Makefile --- .gitignore | 1 + Taskfile.yml | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 Taskfile.yml diff --git a/.gitignore b/.gitignore index ef917cf4..e68aeaaa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ risks.json technical-assets.json stats.json .vscode +.task # Binaries for programs and plugins *.exe diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..a4c71359 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,62 @@ +version: '3' + +vars: + AD_GLOBAL_REQUIRES: --require asciidoctor-diagram --verbose --require asciidoctor-pdf --backend pdf --attribute allow-uri-read + CONTAINER: asciidoctor/docker-asciidoctor + # CONTAINER: docker-asciidoctor:local + DOCKER_ASCIIDOCTOR: docker run -it -u $(id -u):$(id -g) -v $(pwd):/documents/ {{.CONTAINER}} asciidoctor {{.AD_GLOBAL_REQUIRES}} --require asciidoctor-kroki + GIT_SHORT_SHA: + sh: git log -n 1 --format=%h + +env: + GOOS: linux + GO111MODULE: on + +tasks: + default: + deps: [convert-example-project-adoc-to-pdf] + + build-and-test: + desc: build threagile and run tests + deps: [build-threagile, run-tests] + + build-threagile: + desc: build threagile + sources: + - "**/*.go" + - exclude: "**/*_test.go" + generates: + - bin/threagile + vars: + GOFLAGS: -a -ldflags="-s -w -X main.buildTimestamp=$(shell date '+%Y%m%d%H%M%S')" + cmds: + - go mod download + - go build -o bin/threagile cmd/threagile/main.go + + run-tests: + desc: run threagile tests + deps: [build-threagile] + cmds: + - go test ./... + + convert-example-project-adoc-to-pdf: + desc: create example project and create a pdf from the asciidoctor output + deps: [create-example-project] + dir: /tmp/threagile-test/adocReport + cmds: + - "{{.DOCKER_ASCIIDOCTOR}} --attribute DOC_VERSION={{.GIT_SHORT_SHA}} --attribute pdf-themesdir=/documents/theme --attribute pdf-theme=pdf /documents/00_main.adoc" + - cp /tmp/threagile-test/adocReport/00_main.pdf /tmp/report.pdf + - echo "Open report with \"xdg-open /tmp/report.pdf\"" + + create-example-project: + desc: create the example project + deps: [build-threagile] + cmds: + - mkdir -p /tmp/threagile-test + - ./bin/threagile analyze-model + --model ./demo/example/threagile.yaml + --output /tmp/threagile-test + --ignore-orphaned-risk-tracking + --background report/template/background.pdf + --app-dir . + --generate-report-pdf From 5c42835adf4322f0f07186368526c5208f9b8705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Tue, 2 Jul 2024 00:19:32 +0200 Subject: [PATCH 02/16] extract some methods from the pdf generator to a common file --- pkg/report/report-helper.go | 138 ++++++++++++++++++++++++++++++++++++ pkg/report/report.go | 131 ---------------------------------- 2 files changed, 138 insertions(+), 131 deletions(-) create mode 100644 pkg/report/report-helper.go diff --git a/pkg/report/report-helper.go b/pkg/report/report-helper.go new file mode 100644 index 00000000..525793f7 --- /dev/null +++ b/pkg/report/report-helper.go @@ -0,0 +1,138 @@ +package report + +import ( + "sort" + + "github.com/threagile/threagile/pkg/types" +) + +func filteredByRiskStatus(parsedModel *types.Model, status types.RiskStatus) []*types.Risk { + filteredRisks := make([]*types.Risk, 0) + for _, risks := range parsedModel.GeneratedRisksByCategory { + for _, risk := range risks { + if risk.RiskStatus == status { + filteredRisks = append(filteredRisks, risk) + } + } + } + return filteredRisks +} + +func filteredByRiskFunction(parsedModel *types.Model, function types.RiskFunction) []*types.Risk { + filteredRisks := make([]*types.Risk, 0) + for categoryId, risks := range parsedModel.GeneratedRisksByCategory { + for _, risk := range risks { + category := parsedModel.GetRiskCategory(categoryId) + if category.Function == function { + filteredRisks = append(filteredRisks, risk) + } + } + } + return filteredRisks +} + +func reduceToRiskStatus(risks []*types.Risk, status types.RiskStatus) []*types.Risk { + filteredRisks := make([]*types.Risk, 0) + for _, risk := range risks { + if risk.RiskStatus == status { + filteredRisks = append(filteredRisks, risk) + } + } + return filteredRisks +} + +func reduceToFunctionRisk(parsedModel *types.Model, risksByCategory map[string][]*types.Risk, function types.RiskFunction) map[string][]*types.Risk { + result := make(map[string][]*types.Risk) + for categoryId, risks := range risksByCategory { + for _, risk := range risks { + category := parsedModel.GetRiskCategory(categoryId) + if category.Function == function { + result[categoryId] = append(result[categoryId], risk) + } + } + } + return result +} + +func reduceToSTRIDERisk(parsedModel *types.Model, risksByCategory map[string][]*types.Risk, stride types.STRIDE) map[string][]*types.Risk { + result := make(map[string][]*types.Risk) + for categoryId, risks := range risksByCategory { + for _, risk := range risks { + category := parsedModel.GetRiskCategory(categoryId) + if category != nil && category.STRIDE == stride { + result[categoryId] = append(result[categoryId], risk) + } + } + } + return result +} + +func countRisks(risksByCategory map[string][]*types.Risk) int { + result := 0 + for _, risks := range risksByCategory { + result += len(risks) + } + return result +} + +func totalRiskCount(parsedModel *types.Model) int { + count := 0 + for _, risks := range parsedModel.GeneratedRisksByCategory { + count += len(risks) + } + return count +} + +func sortedTechnicalAssetsByRAAAndTitle(parsedModel *types.Model) []*types.TechnicalAsset { + assets := make([]*types.TechnicalAsset, 0) + for _, asset := range parsedModel.TechnicalAssets { + assets = append(assets, asset) + } + sort.Sort(types.ByTechnicalAssetRAAAndTitleSort(assets)) + return assets +} + +func sortedKeysOfQuestions(parsedModel *types.Model) []string { + keys := make([]string, 0) + for k := range parsedModel.Questions { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func filteredBySeverity(parsedModel *types.Model, severity types.RiskSeverity) []*types.Risk { + filteredRisks := make([]*types.Risk, 0) + for _, risks := range parsedModel.GeneratedRisksByCategory { + for _, risk := range risks { + if risk.Severity == severity { + filteredRisks = append(filteredRisks, risk) + } + } + } + return filteredRisks +} + +func sortedTechnicalAssetsByRiskSeverityAndTitle(parsedModel *types.Model) []*types.TechnicalAsset { + assets := make([]*types.TechnicalAsset, 0) + for _, asset := range parsedModel.TechnicalAssets { + assets = append(assets, asset) + } + sortByTechnicalAssetRiskSeverityAndTitleStillAtRisk(assets, parsedModel) + return assets +} + +func identifiedDataBreachProbabilityStillAtRisk(parsedModel *types.Model, dataAsset *types.DataAsset) types.DataBreachProbability { + highestProbability := types.Improbable + for _, risk := range filteredByStillAtRisk(parsedModel) { + for _, techAsset := range risk.DataBreachTechnicalAssetIDs { + if contains(parsedModel.TechnicalAssets[techAsset].DataAssetsProcessed, dataAsset.Id) { + if risk.DataBreachProbability > highestProbability { + highestProbability = risk.DataBreachProbability + break + } + } + } + } + return highestProbability +} diff --git a/pkg/report/report.go b/pkg/report/report.go index 282fb3e4..12b60539 100644 --- a/pkg/report/report.go +++ b/pkg/report/report.go @@ -662,15 +662,6 @@ func sortedKeysOfSharedRuntime(model *types.Model) []string { return keys } -func sortedTechnicalAssetsByRiskSeverityAndTitle(parsedModel *types.Model) []*types.TechnicalAsset { - assets := make([]*types.TechnicalAsset, 0) - for _, asset := range parsedModel.TechnicalAssets { - assets = append(assets, asset) - } - sortByTechnicalAssetRiskSeverityAndTitleStillAtRisk(assets, parsedModel) - return assets -} - func sortByTechnicalAssetRiskSeverityAndTitleStillAtRisk(assets []*types.TechnicalAsset, parsedModel *types.Model) { sort.Slice(assets, func(i, j int) bool { risksLeft := types.ReduceToOnlyStillAtRisk(parsedModel.GeneratedRisks(assets[i])) @@ -1299,41 +1290,6 @@ func (r *pdfReporter) createRiskMitigationStatus(parsedModel *types.Model, tempF return nil } -func filteredByRiskStatus(parsedModel *types.Model, status types.RiskStatus) []*types.Risk { - filteredRisks := make([]*types.Risk, 0) - for _, risks := range parsedModel.GeneratedRisksByCategory { - for _, risk := range risks { - if risk.RiskStatus == status { - filteredRisks = append(filteredRisks, risk) - } - } - } - return filteredRisks -} - -func filteredByRiskFunction(parsedModel *types.Model, function types.RiskFunction) []*types.Risk { - filteredRisks := make([]*types.Risk, 0) - for categoryId, risks := range parsedModel.GeneratedRisksByCategory { - for _, risk := range risks { - category := parsedModel.GetRiskCategory(categoryId) - if category.Function == function { - filteredRisks = append(filteredRisks, risk) - } - } - } - return filteredRisks -} - -func reduceToRiskStatus(risks []*types.Risk, status types.RiskStatus) []*types.Risk { - filteredRisks := make([]*types.Risk, 0) - for _, risk := range risks { - if risk.RiskStatus == status { - filteredRisks = append(filteredRisks, risk) - } - } - return filteredRisks -} - // CAUTION: Long labels might cause endless loop, then remove labels and render them manually later inside the PDF func (r *pdfReporter) embedStackedBarChart(sbcChart chart.StackedBarChart, x float64, y float64, tempFolder string) error { tmpFilePNG, err := os.CreateTemp(tempFolder, "chart-*-.png") @@ -1512,15 +1468,6 @@ func (r *pdfReporter) createOutOfScopeAssets(parsedModel *types.Model) { r.pdf.SetDashPattern([]float64{}, 0) } -func sortedTechnicalAssetsByRAAAndTitle(parsedModel *types.Model) []*types.TechnicalAsset { - assets := make([]*types.TechnicalAsset, 0) - for _, asset := range parsedModel.TechnicalAssets { - assets = append(assets, asset) - } - sort.Sort(types.ByTechnicalAssetRAAAndTitleSort(assets)) - return assets -} - func (r *pdfReporter) createModelFailures(parsedModel *types.Model) { r.pdf.SetTextColor(0, 0, 0) modelFailures := flattenRiskSlice(filterByModelFailures(parsedModel, parsedModel.GeneratedRisksByCategory)) @@ -2010,19 +1957,6 @@ func (r *pdfReporter) createAssignmentByFunction(parsedModel *types.Model) { r.pdf.SetDashPattern([]float64{}, 0) } -func reduceToFunctionRisk(parsedModel *types.Model, risksByCategory map[string][]*types.Risk, function types.RiskFunction) map[string][]*types.Risk { - result := make(map[string][]*types.Risk) - for categoryId, risks := range risksByCategory { - for _, risk := range risks { - category := parsedModel.GetRiskCategory(categoryId) - if category.Function == function { - result[categoryId] = append(result[categoryId], risk) - } - } - } - return result -} - func (r *pdfReporter) createSTRIDE(parsedModel *types.Model) { r.pdf.SetTextColor(0, 0, 0) title := "STRIDE Classification of Identified Risks" @@ -2228,14 +2162,6 @@ func (r *pdfReporter) createSTRIDE(parsedModel *types.Model) { r.pdf.SetDashPattern([]float64{}, 0) } -func countRisks(risksByCategory map[string][]*types.Risk) int { - result := 0 - for _, risks := range risksByCategory { - result += len(risks) - } - return result -} - func getRiskCategories(parsedModel *types.Model, categoryIDs []string) []*types.RiskCategory { categoryMap := make(map[string]*types.RiskCategory) for _, categoryId := range categoryIDs { @@ -2291,19 +2217,6 @@ func keysAsSlice(categories map[string]struct{}) []string { return result } -func reduceToSTRIDERisk(parsedModel *types.Model, risksByCategory map[string][]*types.Risk, stride types.STRIDE) map[string][]*types.Risk { - result := make(map[string][]*types.Risk) - for categoryId, risks := range risksByCategory { - for _, risk := range risks { - category := parsedModel.GetRiskCategory(categoryId) - if category != nil && category.STRIDE == stride { - result[categoryId] = append(result[categoryId], risk) - } - } - } - return result -} - func (r *pdfReporter) createSecurityRequirements(parsedModel *types.Model) { uni := r.pdf.UnicodeTranslatorFromDescriptor("") r.pdf.SetTextColor(0, 0, 0) @@ -2433,15 +2346,6 @@ func (r *pdfReporter) createQuestions(parsedModel *types.Model) { } } -func sortedKeysOfQuestions(parsedModel *types.Model) []string { - keys := make([]string, 0) - for k := range parsedModel.Questions { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - func (r *pdfReporter) createTagListing(parsedModel *types.Model) { r.pdf.SetTextColor(0, 0, 0) chapTitle := "Tag Listing" @@ -3615,18 +3519,6 @@ func (r *pdfReporter) createTechnicalAssets(parsedModel *types.Model) { } } -func filteredBySeverity(parsedModel *types.Model, severity types.RiskSeverity) []*types.Risk { - filteredRisks := make([]*types.Risk, 0) - for _, risks := range parsedModel.GeneratedRisksByCategory { - for _, risk := range risks { - if risk.Severity == severity { - filteredRisks = append(filteredRisks, risk) - } - } - } - return filteredRisks -} - func (r *pdfReporter) createDataAssets(parsedModel *types.Model) { uni := r.pdf.UnicodeTranslatorFromDescriptor("") title := "Identified Data Breach Probabilities by Data Asset" @@ -3987,21 +3879,6 @@ func identifiedDataBreachProbabilityRisksStillAtRisk(parsedModel *types.Model, d return result } -func identifiedDataBreachProbabilityStillAtRisk(parsedModel *types.Model, dataAsset *types.DataAsset) types.DataBreachProbability { - highestProbability := types.Improbable - for _, risk := range filteredByStillAtRisk(parsedModel) { - for _, techAsset := range risk.DataBreachTechnicalAssetIDs { - if contains(parsedModel.TechnicalAssets[techAsset].DataAssetsProcessed, dataAsset.Id) { - if risk.DataBreachProbability > highestProbability { - highestProbability = risk.DataBreachProbability - break - } - } - } - } - return highestProbability -} - func isDataBreachPotentialStillAtRisk(parsedModel *types.Model, dataAsset *types.DataAsset) bool { for _, risk := range filteredByStillAtRisk(parsedModel) { for _, techAsset := range risk.DataBreachTechnicalAssetIDs { @@ -4025,14 +3902,6 @@ func filteredByStillAtRisk(parsedModel *types.Model) []*types.Risk { return filteredRisks } -func totalRiskCount(parsedModel *types.Model) int { - count := 0 - for _, risks := range parsedModel.GeneratedRisksByCategory { - count += len(risks) - } - return count -} - func (r *pdfReporter) createTrustBoundaries(parsedModel *types.Model) { uni := r.pdf.UnicodeTranslatorFromDescriptor("") title := "Trust Boundaries" From dd33b03a7259772089865e8e1220a1e203225dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Wed, 3 Jul 2024 21:52:59 +0200 Subject: [PATCH 03/16] adoc generator as alternative for the current pdf generator --- Taskfile.yml | 5 +- internal/threagile/config.go | 9 + internal/threagile/consts.go | 1 + internal/threagile/flags.go | 4 + internal/threagile/root.go | 7 +- pkg/report/adocReport.go | 2152 ++++++++++++++++++++++++++++++++++ pkg/report/generate.go | 37 +- report/threagile-logo.png | Bin 0 -> 65555 bytes 8 files changed, 2211 insertions(+), 4 deletions(-) create mode 100644 pkg/report/adocReport.go create mode 100644 report/threagile-logo.png diff --git a/Taskfile.yml b/Taskfile.yml index a4c71359..0cc8031b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -57,6 +57,7 @@ tasks: --model ./demo/example/threagile.yaml --output /tmp/threagile-test --ignore-orphaned-risk-tracking - --background report/template/background.pdf --app-dir . - --generate-report-pdf + --generate-report-adoc + --generate-report-pdf=0 + # --background ./report/template/background.pdf diff --git a/internal/threagile/config.go b/internal/threagile/config.go index 451e3f97..c81095f9 100644 --- a/internal/threagile/config.go +++ b/internal/threagile/config.go @@ -36,6 +36,7 @@ type Config struct { JsonTechnicalAssetsFilename string JsonStatsFilename string TemplateFilename string + ReportLogoImagePath string TechnologyFilename string RiskRulesPlugins []string @@ -88,6 +89,7 @@ func (c *Config) Defaults(buildTimestamp string) *Config { JsonTechnicalAssetsFilename: JsonTechnicalAssetsFilename, JsonStatsFilename: JsonStatsFilename, TemplateFilename: TemplateFilename, + ReportLogoImagePath: ReportLogoImagePath, TechnologyFilename: "", RiskRulesPlugins: make([]string, 0), @@ -274,6 +276,9 @@ func (c *Config) Merge(config Config, values map[string]any) { case strings.ToLower("TemplateFilename"): c.TemplateFilename = config.TemplateFilename + case strings.ToLower("ReportLogoImagePath"): + c.TemplateFilename = config.ReportLogoImagePath + case strings.ToLower("TechnologyFilename"): c.TechnologyFilename = config.TechnologyFilename @@ -469,6 +474,10 @@ func (c *Config) GetTemplateFilename() string { return c.TemplateFilename } +func (c *Config) GetReportLogoImagePath() string { + return c.ReportLogoImagePath +} + func (c *Config) GetRiskRulesPlugins() []string { return c.RiskRulesPlugins } diff --git a/internal/threagile/consts.go b/internal/threagile/consts.go index 11bea1c3..e9059e27 100644 --- a/internal/threagile/consts.go +++ b/internal/threagile/consts.go @@ -19,6 +19,7 @@ const ( JsonTechnicalAssetsFilename = "technical-assets.json" JsonStatsFilename = "stats.json" TemplateFilename = "background.pdf" + ReportLogoImagePath = "report/threagile-logo.png" DataFlowDiagramFilenameDOT = "data-flow-diagram.gv" DataFlowDiagramFilenamePNG = "data-flow-diagram.png" DataAssetDiagramFilenameDOT = "data-asset-diagram.gv" diff --git a/internal/threagile/flags.go b/internal/threagile/flags.go index 1901d7a7..aca0039e 100644 --- a/internal/threagile/flags.go +++ b/internal/threagile/flags.go @@ -27,6 +27,7 @@ const ( skipRiskRulesFlagName = "skip-risk-rules" ignoreOrphanedRiskTrackingFlagName = "ignore-orphaned-risk-tracking" templateFileNameFlagName = "background" + reportLogoImagePathFlagName = "reportLogoImagePath" generateDataFlowDiagramFlagName = "generate-data-flow-diagram" generateDataAssetDiagramFlagName = "generate-data-asset-diagram" @@ -36,6 +37,7 @@ const ( generateRisksExcelFlagName = "generate-risks-excel" generateTagsExcelFlagName = "generate-tags-excel" generateReportPDFFlagName = "generate-report-pdf" + generateReportADOCFlagName = "generate-report-adoc" ) type Flags struct { @@ -53,6 +55,7 @@ type Flags struct { customRiskRulesPluginFlag string ignoreOrphanedRiskTrackingFlag bool templateFileNameFlag string + reportLogoImagePathFlag string diagramDpiFlag int generateDataFlowDiagramFlag bool @@ -63,4 +66,5 @@ type Flags struct { generateRisksExcelFlag bool generateTagsExcelFlag bool generateReportPDFFlag bool + generateReportADOCFlag bool } diff --git a/internal/threagile/root.go b/internal/threagile/root.go index 67f28ee4..4a0d0887 100644 --- a/internal/threagile/root.go +++ b/internal/threagile/root.go @@ -71,6 +71,7 @@ func (what *Threagile) initRoot() *Threagile { what.rootCmd.PersistentFlags().StringVar(&what.flags.skipRiskRulesFlag, skipRiskRulesFlagName, strings.Join(defaultConfig.SkipRiskRules, ","), "comma-separated list of risk rules (by their ID) to skip") what.rootCmd.PersistentFlags().BoolVar(&what.flags.ignoreOrphanedRiskTrackingFlag, ignoreOrphanedRiskTrackingFlagName, defaultConfig.IgnoreOrphanedRiskTracking, "ignore orphaned risk tracking (just log them) not matching a concrete risk") what.rootCmd.PersistentFlags().StringVar(&what.flags.templateFileNameFlag, templateFileNameFlagName, defaultConfig.TemplateFilename, "background pdf file") + what.rootCmd.PersistentFlags().StringVar(&what.flags.reportLogoImagePathFlag, reportLogoImagePathFlagName, defaultConfig.ReportLogoImagePath, "reportLogoImagePath") what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateDataFlowDiagramFlag, generateDataFlowDiagramFlagName, true, "generate data flow diagram") what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateDataAssetDiagramFlag, generateDataAssetDiagramFlagName, true, "generate data asset diagram") @@ -80,7 +81,7 @@ func (what *Threagile) initRoot() *Threagile { what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateRisksExcelFlag, generateRisksExcelFlagName, true, "generate risks excel") what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateTagsExcelFlag, generateTagsExcelFlagName, true, "generate tags excel") what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateReportPDFFlag, generateReportPDFFlagName, true, "generate report pdf, including diagrams") - + what.rootCmd.PersistentFlags().BoolVar(&what.flags.generateReportADOCFlag, generateReportADOCFlagName, true, "generate report adoc, including diagrams") return what } @@ -214,6 +215,7 @@ func (what *Threagile) readCommands() *report.GenerateCommands { commands.RisksExcel = what.flags.generateRisksExcelFlag commands.TagsExcel = what.flags.generateTagsExcelFlag commands.ReportPDF = what.flags.generateReportPDFFlag + commands.ReportADOC = what.flags.generateReportADOCFlag return commands } @@ -265,6 +267,9 @@ func (what *Threagile) readConfig(cmd *cobra.Command, buildTimestamp string) *Co if isFlagOverridden(flags, templateFileNameFlagName) { cfg.TemplateFilename = what.flags.templateFileNameFlag } + if isFlagOverridden(flags, reportLogoImagePathFlagName) { + cfg.ReportLogoImagePath = what.flags.reportLogoImagePathFlag + } return cfg } diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go new file mode 100644 index 00000000..2ee0c307 --- /dev/null +++ b/pkg/report/adocReport.go @@ -0,0 +1,2152 @@ +package report + +import ( + "fmt" + "image" + "io" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/threagile/threagile/pkg/types" +) + +type adocReport struct { + targetDirectory string + model *types.Model + mainFile *os.File + imagesDir string + + riskRules types.RiskRules + + iconsType string + tocDepth int +} + +func copyFile(source string, destination string) error { + src, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = src.Close() }() + dst, err := os.Create(destination) + if err != nil { + return err + } + defer func() { _ = dst.Close() }() + _, err = io.Copy(dst, src) + if err != nil { + return err + } + dst.Close() + src.Close() + return nil +} + +func fixBasicHtml(inputWithHtml string) string { + result := strings.Replace(inputWithHtml, "", "*", -1) + result = strings.Replace(result, "", "*", -1) + + result = strings.Replace(result, "", "_", -1) + result = strings.Replace(result, "", "_", -1) + + result = strings.Replace(result, "", "[.underline]#", -1) + result = strings.Replace(result, "", "#", -1) + + result = strings.Replace(result, "
", "\n", -1) + result = strings.Replace(result, "
", "\n", -1) + return result +} + +func NewAdocReport(targetDirectory string, riskRules types.RiskRules) adocReport { + adoc := adocReport{ + targetDirectory: filepath.Join(targetDirectory, "adocReport"), + iconsType: "font", + tocDepth: 2, + imagesDir: filepath.Join(targetDirectory, "adocReport", "images"), + riskRules: riskRules, + } + return adoc +} + +func writeLine(file *os.File, line string) { + file.WriteString(line + "\n") +} + +func (adoc adocReport) writeDefaultTheme(logoImagePath string) error { + err := os.MkdirAll(filepath.Join(adoc.targetDirectory, "theme"), 0755) + if err != nil { + return err + } + err = os.MkdirAll(adoc.imagesDir, 0755) + if err != nil { + return err + } + theme, err := os.Create(filepath.Join(adoc.targetDirectory, "theme", "pdf-theme.yml")) + if err != nil { + return err + } + adocLogoPath := "" + if logoImagePath != "" { + if _, err := os.Stat(logoImagePath); err == nil { + suffix := filepath.Ext(logoImagePath) + adocLogoPath = "logo" + suffix + copyFile(logoImagePath, filepath.Join(adoc.targetDirectory, "theme", adocLogoPath)) + } else { + log.Println("logo image path does not exist: " + logoImagePath) + } + } + + writeLine(theme, `extends: default +page: + layout: portrait + margin: [3cm, 2.5cm, 2.7cm, 2.5cm] +title-page: + authors: + content: "{author}, {author-homepage}[]" +`) + if adocLogoPath != "" { + writeLine(theme, + ` logo: + image: image:`+adocLogoPath+`[]`) + } + writeLine(theme, + `header: + height: 2cm + line-height: 1 + recto: + center: + content: "{section-or-chapter-title} - `+adoc.model.Title+`" + verso: + center: + content: "{section-or-chapter-title} - `+adoc.model.Title+`" +footer: + height: 2cm + line-height: 1.2 + recto: + center: + content: | + --confidential -- + {document-title} + left: + content: "Version: {DOC_VERSION}" + right: + content: "Page {page-number} of {page-count}" + verso: + center: + content: | + --confidential -- + {document-title} + left: + content: "Version: {DOC_VERSION}" + right: + content: Page {page-number} of {page-count} +role: + LowRisk: + font-color: `+rgbHexColorLowRisk()+` + MediumRisk: + font-color: `+rgbHexColorMediumRisk()+` + ElevatedRisk: + font-color: `+rgbHexColorElevatedRisk()+` + HighRisk: + font-color: `+rgbHexColorHighRisk()+` + CriticalRisk: + font-color: `+rgbHexColorCriticalRisk()+` + OutOfScope: + font-color: #7f7f7f + GreyText: + font-color: #505050 + LightGreyText: + font-color: #646464 + ModelFailure: + font-color: #945200 + RiskStatusFalsePositive: + font-color: `+rgbHexColorRiskStatusFalsePositive()+` + RiskStatusMitigated: + font-color: `+rgbHexColorRiskStatusMitigated()+` + RiskStatusInProgress: + font-color: `+rgbHexColorRiskStatusInProgress()+` + RiskStatusAccepted: + font-color: `+rgbHexColorRiskStatusAccepted()+` + RiskStatusInDiscussion: + font-color: `+rgbHexColorRiskStatusInDiscussion()+` + RiskStatusUnchecked: + font-color: `+RgbHexColorRiskStatusUnchecked()+` + Twilight: + font-color: `+rgbHexColorTwilight()+` + SmallGrey: + font-size: 0.5em + font-color: #505050 +`) + + theme.Close() + return nil +} + +func (adoc adocReport) writeMainLine(line string) { + writeLine(adoc.mainFile, line) +} + +func (adoc adocReport) WriteReport(model *types.Model, + dataFlowDiagramFilenamePNG string, + dataAssetDiagramFilenamePNG string, + modelFilename string, + skipRiskRules []string, + buildTimestamp string, + threagileVersion string, + modelHash string, + introTextRAA string, + customRiskRules types.RiskRules, + logoImagePath string) error { + + adoc.model = model + err := adoc.initReport() + if err != nil { + return err + } + err = adoc.writeDefaultTheme(logoImagePath) + if err != nil { + return err + } + // err = adoc.createDefaultTheme() FIXME + adoc.writeTitleAndPreamble() + err = adoc.writeManagementSummery() + if err != nil { + return err + } + + err = adoc.writeImpactInitialRisks() + if err != nil { + return fmt.Errorf("error creating impact initial risks: %w", err) + } + err = adoc.writeRiskMitigationStatus() + if err != nil { + return fmt.Errorf("error creating risk mitigation status: %w", err) + } + err = adoc.writeImpactRemainingRisks() + if err != nil { + return fmt.Errorf("error creating impact remaining risks: %w", err) + } + err = adoc.writeTargetDescription(filepath.Dir(modelFilename)) + if err != nil { + return fmt.Errorf("error creating target description: %w", err) + } + err = adoc.writeDataFlowDiagram(dataFlowDiagramFilenamePNG) + if err != nil { + return fmt.Errorf("error creating data flow diagram section: %w", err) + } + err = adoc.writeSecurityRequirements() + if err != nil { + return fmt.Errorf("error creating security requirements: %w", err) + } + err = adoc.writeAbuseCases() + if err != nil { + return fmt.Errorf("error creating abuse cases: %w", err) + } + err = adoc.writeTagListing() + if err != nil { + return fmt.Errorf("error creating tag listing: %w", err) + } + err = adoc.writeSTRIDE() + if err != nil { + return fmt.Errorf("error creating STRIDE: %w", err) + } + err = adoc.writeAssignmentByFunction() + if err != nil { + return fmt.Errorf("error creating assignment by function: %w", err) + } + err = adoc.writeRAA(introTextRAA) + if err != nil { + return fmt.Errorf("error creating RAA: %w", err) + } + err = adoc.writeDataRiskMapping(dataAssetDiagramFilenamePNG) + if err != nil { + return fmt.Errorf("error creating data risk mapping: %w", err) + } + err = adoc.writeOutOfScopeAssets() + if err != nil { + return fmt.Errorf("error creating Out of Scope Assets: %w", err) + } + err = adoc.writeModelFailures() + if err != nil { + return fmt.Errorf("error creating model failures: %w", err) + } + err = adoc.writeQuestions() + if err != nil { + return fmt.Errorf("error creating questions: %w", err) + } + err = adoc.writeRiskCategories() + if err != nil { + return fmt.Errorf("error creating risk categories: %w", err) + } + err = adoc.writeTechnicalAssets() + if err != nil { + return fmt.Errorf("error creating technical assets: %w", err) + } + err = adoc.writeDataAssets() + if err != nil { + return fmt.Errorf("error creating data assets: %w", err) + } + err = adoc.writeTrustBoundaries() + if err != nil { + return fmt.Errorf("error creating trust boundaries: %w", err) + } + err = adoc.writeSharedRuntimes() + if err != nil { + return fmt.Errorf("error creating shared runtimes: %w", err) + } + err = adoc.writeRiskRulesChecked(modelFilename, skipRiskRules, buildTimestamp, threagileVersion, modelHash, customRiskRules) + if err != nil { + return fmt.Errorf("error creating risk rules checked: %w", err) + } + err = adoc.writeDisclaimer() + if err != nil { + return fmt.Errorf("error creating disclaimer: %w", err) + } + return nil +} + +func (adoc *adocReport) initReport() error { + os.RemoveAll(adoc.targetDirectory) + err := os.MkdirAll(adoc.targetDirectory, 0755) + if err != nil { + return err + } + adoc.mainFile, err = os.Create(filepath.Join(adoc.targetDirectory, "00_main.adoc")) + if err != nil { + return err + } + + return nil +} + +func (adoc adocReport) writeTitleAndPreamble() { + adoc.writeMainLine("= Threat Model Report: " + adoc.model.Title) + adoc.writeMainLine(":title-page:") + adoc.writeMainLine(":author: " + adoc.model.Author.Name) + if strings.HasPrefix(adoc.model.Author.Homepage, "http") { + adoc.writeMainLine(`:author-homepage: ` + adoc.model.Author.Homepage) + } else { + adoc.writeMainLine(`:author-homepage: https://` + adoc.model.Author.Homepage) + } + adoc.writeMainLine(":email: " + adoc.model.Author.Contact) + adoc.writeMainLine(":toc:") + adoc.writeMainLine(":toclevels: " + strconv.Itoa(adoc.tocDepth)) + adoc.writeMainLine(":icons: " + adoc.iconsType) + reportDate := adoc.model.Date + if reportDate.IsZero() { + reportDate = types.Date{Time: time.Now()} + } + adoc.writeMainLine(":revdate: " + reportDate.Format("2 January 2006")) + adoc.writeMainLine("") +} + +func (adoc adocReport) writeManagementSummery() error { + filename := "01_ManagementSummary.adoc" + ms, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + writeLine(ms, "= Management Summary") + writeLine(ms, "") + writeLine(ms, "Threagile toolkit was used to model the architecture of \""+adoc.model.Title+"\" and derive risks by analyzing the components and data flows.") + writeLine(ms, "The risks identified during this analysis are shown in the following chapters.") + writeLine(ms, "Identified risks during threat modeling do not necessarily mean that the "+ + "vulnerability associated with this risk actually exists: it is more to be seen as a list"+ + " of potential risks and threats, which should be individually reviewed and reduced by removing false positives.") + writeLine(ms, "For the remaining risks it should be checked in the design and implementation of \""+adoc.model.Title+"\" whether the mitigation advices have been applied or not.") + writeLine(ms, "\n\n") + writeLine(ms, "Each risk finding references a chapter of the OWASP ASVS (Application Security Verification Standard) audit checklist.") + writeLine(ms, "The OWASP ASVS checklist should be considered as an inspiration by architects and developers to further harden the application in a Defense-in-Depth approach.") + writeLine(ms, "Additionally, for each risk finding a link towards a matching OWASP Cheat Sheet or similar with technical details about how to implement a mitigation is given.") + writeLine(ms, "\n\n") + writeLine(ms, "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" initial risks* in *"+strconv.Itoa(len(adoc.model.GeneratedRisksByCategory))+" categories* have been identified during the threat modeling process:") + writeLine(ms, "\n\n") + + countCritical := len(filteredBySeverity(adoc.model, types.CriticalSeverity)) + countHigh := len(filteredBySeverity(adoc.model, types.HighSeverity)) + countElevated := len(filteredBySeverity(adoc.model, types.ElevatedSeverity)) + countMedium := len(filteredBySeverity(adoc.model, types.MediumSeverity)) + countLow := len(filteredBySeverity(adoc.model, types.LowSeverity)) + + countStatusUnchecked := len(filteredByRiskStatus(adoc.model, types.Unchecked)) + countStatusInDiscussion := len(filteredByRiskStatus(adoc.model, types.InDiscussion)) + countStatusAccepted := len(filteredByRiskStatus(adoc.model, types.Accepted)) + countStatusInProgress := len(filteredByRiskStatus(adoc.model, types.InProgress)) + countStatusMitigated := len(filteredByRiskStatus(adoc.model, types.Mitigated)) + countStatusFalsePositive := len(filteredByRiskStatus(adoc.model, types.FalsePositive)) + + pieCharts := `[cols="a,a",frame=none,grid=none] +|=== +| +[mermaid] +.... +%%{init: {'pie' : {'textPosition' : 0.5}, 'theme': 'base', 'themeVariables': { 'pie1': '` + rgbHexColorCriticalRisk() + `', 'pie2': '` + rgbHexColorHighRisk() + `', 'pie3': '` + rgbHexColorElevatedRisk() + `', 'pie4': '` + rgbHexColorMediumRisk() + `', 'pie5': '` + rgbHexColorLowRisk() + `'}}}%% +pie showData + "critical risk" : ` + strconv.Itoa(countCritical) + ` + "high risk" : ` + strconv.Itoa(countHigh) + ` + "elevated risk" : ` + strconv.Itoa(countElevated) + ` + "medium risk" : ` + strconv.Itoa(countMedium) + ` + "low risk" : ` + strconv.Itoa(countLow) + ` +.... + +| +[mermaid] +.... +%%{init: {'pie' : {'textPosition' : 0.5}, 'theme': 'base', 'themeVariables': { 'pie1': '` + RgbHexColorRiskStatusUnchecked() + `', 'pie2': '` + rgbHexColorRiskStatusInDiscussion() + `', 'pie3': '` + rgbHexColorRiskStatusAccepted() + `', 'pie4': '` + rgbHexColorRiskStatusInProgress() + `', 'pie5': '` + rgbHexColorRiskStatusMitigated() + `', 'pie5': '` + rgbHexColorRiskStatusFalsePositive() + `'}}}%% +pie showData + "unchecked" : ` + strconv.Itoa(countStatusUnchecked) + ` + "in discussion" : ` + strconv.Itoa(countStatusInDiscussion) + ` + "accepted" : ` + strconv.Itoa(countStatusAccepted) + ` + "in progress" : ` + strconv.Itoa(countStatusInProgress) + ` + "mitigated" : ` + strconv.Itoa(countStatusMitigated) + ` + "false positive" : ` + strconv.Itoa(countStatusFalsePositive) + ` +.... +|=== +` + writeLine(ms, pieCharts) + // individual management summary comment + if len(adoc.model.ManagementSummaryComment) > 0 { + writeLine(ms, "\n\n\n"+fixBasicHtml(adoc.model.ManagementSummaryComment)) + } + + ms.Close() + return nil +} + +func colorPrefixBySeverity(severity types.RiskSeverity, smallFont bool) (string, string) { + start := "" + switch severity { + case types.CriticalSeverity: + start = "[.CriticalRisk" + case types.HighSeverity: + start = "[.HighRisk" + case types.ElevatedSeverity: + start = "[.ElevatedRisk" + case types.MediumSeverity: + start = "[.MediumRisk" + case types.LowSeverity: + start = "[.LowRisk" + default: + return "", "" + } + if smallFont { + start += ".small" + } + return start + "]#", "#" +} + +func colorPrefixByDataBreachProbability(probability types.DataBreachProbability, smallFont bool) (string, string) { + switch probability { + case types.Probable: + return colorPrefixBySeverity(types.HighSeverity, smallFont) + case types.Possible: + return colorPrefixBySeverity(types.MediumSeverity, smallFont) + case types.Improbable: + return colorPrefixBySeverity(types.LowSeverity, smallFont) + default: + return "", "" + } +} + +func titleOfSeverity(severity types.RiskSeverity) string { + switch severity { + case types.CriticalSeverity: + return "Critical Risk Severity" + case types.HighSeverity: + return "High Risk Severity" + case types.ElevatedSeverity: + return "Elevated Risk Severity" + case types.MediumSeverity: + return "Medium Risk Severity" + case types.LowSeverity: + return "Low Risk Severity" + default: + return "" + } +} + +func (adoc adocReport) addCategories(f *os.File, risksByCategory map[string][]*types.Risk, initialRisks bool, severity types.RiskSeverity, bothInitialAndRemainingRisks bool, describeDescription bool) error { + describeImpact := true + riskCategories := getRiskCategories(adoc.model, reduceToSeverityRisk(risksByCategory, initialRisks, severity)) + sort.Sort(types.ByRiskCategoryTitleSort(riskCategories)) + for _, riskCategory := range riskCategories { + risksStr := risksByCategory[riskCategory.ID] + if !initialRisks { + risksStr = types.ReduceToOnlyStillAtRisk(risksStr) + } + if len(risksStr) == 0 { + continue + } + + var prefix string + colorPrefix, colorSuffix := colorPrefixBySeverity(severity, false) + switch severity { + case types.CriticalSeverity: + prefix = "Critical: " + case types.HighSeverity: + prefix = "High: " + case types.ElevatedSeverity: + prefix = "Elevated: " + case types.MediumSeverity: + prefix = "Medium: " + case types.LowSeverity: + prefix = "Low: " + default: + prefix = "" + } + if len(types.ReduceToOnlyStillAtRisk(risksStr)) == 0 { + colorPrefix = "" + colorSuffix = "" + } + fullLine := "<<" + riskCategory.ID + "," + colorPrefix + prefix + "*" + riskCategory.Title + "*: " + + count := len(risksStr) + initialStr := "Initial" + if !initialRisks { + initialStr = "Remaining" + } + remainingRisks := types.ReduceToOnlyStillAtRisk(risksStr) + suffix := strconv.Itoa(count) + " " + initialStr + " Risk" + if bothInitialAndRemainingRisks { + suffix = strconv.Itoa(len(remainingRisks)) + " / " + strconv.Itoa(count) + " Risk" + } + if count != 1 { + suffix += "s" + } + suffix += " - Exploitation likelihood is _" + if initialRisks { + suffix += highestExploitationLikelihood(risksStr).Title() + "_ with _" + highestExploitationImpact(risksStr).Title() + "_ impact." + } else { + suffix += highestExploitationLikelihood(remainingRisks).Title() + "_ with _" + highestExploitationImpact(remainingRisks).Title() + "_ impact." + } + + fullLine += suffix + colorSuffix + ">>::" + writeLine(f, fullLine) + + if describeImpact { + writeLine(f, firstParagraph(riskCategory.Impact)) + } else if describeDescription { + writeLine(f, firstParagraph(riskCategory.Description)) + } else { + writeLine(f, firstParagraph(riskCategory.Mitigation)) + } + } + return nil +} + +func (adoc adocReport) impactAnalysis(f *os.File, initialRisks bool) { + count, catCount := totalRiskCount(adoc.model), len(adoc.model.GeneratedRisksByCategory) + riskStr, catStr := "Risks", "Categories" + if count == 1 { + riskStr = "Risk" + } + if catCount == 1 { + catStr = "category" + } + + if initialRisks { + chapTitle := "= Impact Analysis of " + strconv.Itoa(count) + " Initial " + riskStr + " in " + strconv.Itoa(catCount) + " " + catStr + writeLine(f, chapTitle) + } else { + chapTitle := "= Impact Analysis of " + strconv.Itoa(count) + " Remaining " + riskStr + " in " + strconv.Itoa(catCount) + " " + catStr + writeLine(f, chapTitle) + } + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + + riskStr = "risks" + if count == 1 { + riskStr = "risk" + } + initialStr := "initial" + if !initialRisks { + initialStr = "remaining" + } + writeLine(f, + "The most prevalent impacts of the *"+strconv.Itoa(count)+" "+initialStr+" "+riskStr+"*"+ + " (distributed over *"+strconv.Itoa(catCount)+" risk categories*) are "+ + "(taking the severity ratings into account and using the highest for each category)!{fn-risk-findings}") + writeLine(f, "") + adoc.addCategories(f, adoc.model.GeneratedRisksByCategory, initialRisks, types.CriticalSeverity, false, false) + adoc.addCategories(f, adoc.model.GeneratedRisksByCategory, initialRisks, types.HighSeverity, false, false) + adoc.addCategories(f, adoc.model.GeneratedRisksByCategory, initialRisks, types.ElevatedSeverity, false, false) + adoc.addCategories(f, adoc.model.GeneratedRisksByCategory, initialRisks, types.MediumSeverity, false, false) + adoc.addCategories(f, adoc.model.GeneratedRisksByCategory, initialRisks, types.LowSeverity, false, false) +} + +func (adoc adocReport) writeImpactInitialRisks() error { + filename := "02_ImpactIntialRisks.adoc" + ir, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.impactAnalysis(ir, true) + ir.Close() + return nil +} + +func (adoc adocReport) riskMitigationStatus(f *os.File) { + + writeLine(f, "= Risk Mitigation") + writeLine(f, "The following chart gives a high-level overview of the risk tracking status (including mitigated risks):") + + risksCritical := filteredBySeverity(adoc.model, types.CriticalSeverity) + risksHigh := filteredBySeverity(adoc.model, types.HighSeverity) + risksElevated := filteredBySeverity(adoc.model, types.ElevatedSeverity) + risksMedium := filteredBySeverity(adoc.model, types.MediumSeverity) + risksLow := filteredBySeverity(adoc.model, types.LowSeverity) + countStatusUnchecked := len(filteredByRiskStatus(adoc.model, types.Unchecked)) + countStatusInDiscussion := len(filteredByRiskStatus(adoc.model, types.InDiscussion)) + countStatusAccepted := len(filteredByRiskStatus(adoc.model, types.Accepted)) + countStatusInProgress := len(filteredByRiskStatus(adoc.model, types.InProgress)) + countStatusMitigated := len(filteredByRiskStatus(adoc.model, types.Mitigated)) + countStatusFalsePositive := len(filteredByRiskStatus(adoc.model, types.FalsePositive)) + + lowTitle := types.LowSeverity.Title() + " (" + strconv.Itoa(len(risksLow)) + ")" + medTitle := types.MediumSeverity.Title() + " (" + strconv.Itoa(len(risksMedium)) + ")" + elevatedTitle := types.ElevatedSeverity.Title() + " (" + strconv.Itoa(len(risksElevated)) + ")" + highTitle := types.HighSeverity.Title() + " (" + strconv.Itoa(len(risksHigh)) + ")" + criticalTitle := types.CriticalSeverity.Title() + " (" + strconv.Itoa(len(risksCritical)) + ")" + + diagram := ` +[vegalite] +.... +{ + "width": 400, + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "data": { + "values": [ + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.Unchecked))) + `, "status": "Unchecked", "color": "` + RgbHexColorRiskStatusUnchecked() + `"}, + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.InDiscussion))) + `, "status": "InDiscussion", "color": "` + rgbHexColorRiskStatusInDiscussion() + `"}, + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.Accepted))) + `, "status": "Accepted", "color": "` + rgbHexColorRiskStatusAccepted() + `"}, + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.InProgress))) + `, "status": "InProgress", "color": "` + rgbHexColorRiskStatusInProgress() + `"}, + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.Mitigated))) + `, "status": "Mitigated", "color": "` + rgbHexColorRiskStatusMitigated() + `"}, + {"risk": "` + lowTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksLow, types.FalsePositive))) + `, "status": "FalsePositive", "color": "` + rgbHexColorRiskStatusFalsePositive() + `"}, + + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.Unchecked))) + `, "status": "Unchecked", "color": "` + RgbHexColorRiskStatusUnchecked() + `"}, + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.InDiscussion))) + `, "status": "InDiscussion", "color": "` + rgbHexColorRiskStatusInDiscussion() + `"}, + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.Accepted))) + `, "status": "Accepted", "color": "` + rgbHexColorRiskStatusAccepted() + `"}, + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.InProgress))) + `, "status": "InProgress", "color": "` + rgbHexColorRiskStatusInProgress() + `"}, + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.Mitigated))) + `, "status": "Mitigated", "color": "` + rgbHexColorRiskStatusMitigated() + `"}, + {"risk": "` + medTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksMedium, types.FalsePositive))) + `, "status": "FalsePositive", "color": "` + rgbHexColorRiskStatusFalsePositive() + `"}, + + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.Unchecked))) + `, "status": "Unchecked", "color": "` + RgbHexColorRiskStatusUnchecked() + `"}, + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.InDiscussion))) + `, "status": "InDiscussion", "color": "` + rgbHexColorRiskStatusInDiscussion() + `"}, + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.Accepted))) + `, "status": "Accepted", "color": "` + rgbHexColorRiskStatusAccepted() + `"}, + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.InProgress))) + `, "status": "InProgress", "color": "` + rgbHexColorRiskStatusInProgress() + `"}, + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.Mitigated))) + `, "status": "Mitigated", "color": "` + rgbHexColorRiskStatusMitigated() + `"}, + {"risk": "` + elevatedTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksElevated, types.FalsePositive))) + `, "status": "FalsePositive", "color": "` + rgbHexColorRiskStatusFalsePositive() + `"}, + + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.Unchecked))) + `, "status": "Unchecked", "color": "` + RgbHexColorRiskStatusUnchecked() + `"}, + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.InDiscussion))) + `, "status": "InDiscussion", "color": "` + rgbHexColorRiskStatusInDiscussion() + `"}, + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.Accepted))) + `, "status": "Accepted", "color": "` + rgbHexColorRiskStatusAccepted() + `"}, + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.InProgress))) + `, "status": "InProgress", "color": "` + rgbHexColorRiskStatusInProgress() + `"}, + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.Mitigated))) + `, "status": "Mitigated", "color": "` + rgbHexColorRiskStatusMitigated() + `"}, + {"risk": "` + highTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksHigh, types.FalsePositive))) + `, "status": "FalsePositive", "color": "` + rgbHexColorRiskStatusFalsePositive() + `"}, + + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.Unchecked))) + `, "status": "Unchecked", "color": "` + RgbHexColorRiskStatusUnchecked() + `"}, + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.InDiscussion))) + `, "status": "InDiscussion", "color": "` + rgbHexColorRiskStatusInDiscussion() + `"}, + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.Accepted))) + `, "status": "Accepted", "color": "` + rgbHexColorRiskStatusAccepted() + `"}, + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.InProgress))) + `, "status": "InProgress", "color": "` + rgbHexColorRiskStatusInProgress() + `"}, + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.Mitigated))) + `, "status": "Mitigated", "color": "` + rgbHexColorRiskStatusMitigated() + `"}, + {"risk": "` + criticalTitle + `", "value": ` + strconv.Itoa(len(reduceToRiskStatus(risksCritical, types.FalsePositive))) + `, "status": "FalsePositive", "color": "` + rgbHexColorRiskStatusFalsePositive() + `"} + ] + }, + "mark": {"type": "bar", "cornerRadiusTopLeft": 3, "cornerRadiusTopRight": 3}, + "encoding": { + "x": {"field": "risk", "type": "ordinal", "title": "", "axis": { + "labelAngle": 0 + }}, + "y": {"field": "value", "type": "quantitative", "title": "", "axis": { + "orient": "right" + }}, + "color": { + "field": "status", + "scale": { + "domain": ["Unchecked", "InDiscussion", "Accepted", "InProgress", "Mitigated", "FalsePositive"], + "range": ["` + RgbHexColorRiskStatusUnchecked() + `", "` + rgbHexColorRiskStatusInDiscussion() + `", "` + rgbHexColorRiskStatusAccepted() + `", "` + rgbHexColorRiskStatusInProgress() + `", "` + rgbHexColorRiskStatusMitigated() + `", "` + rgbHexColorRiskStatusFalsePositive() + `"] + }, + "legend" : { + "title": "", + "labelExpr": "datum.label == \"Unchecked\" ? \"` + strconv.Itoa(countStatusUnchecked) + + ` unchecked\" : datum.label == \"InDiscussion\" ? \"` + strconv.Itoa(countStatusInDiscussion) + + ` in discussion\" : datum.label == \"Accepted\" ? \"` + strconv.Itoa(countStatusAccepted) + + ` accepted\" : datum.label == \"InProgress\" ? \"` + strconv.Itoa(countStatusInProgress) + + ` in progress\" : datum.label == \"Mitigated\" ? \"` + strconv.Itoa(countStatusMitigated) + + ` mitigated\" : datum.label == \"FalsePositive\" ? \"` + strconv.Itoa(countStatusFalsePositive) + + ` false positive\" : \"\"" + } + } + } +} +.... +` + writeLine(f, diagram) + writeLine(f, "") + stillAtRisk := filteredByStillAtRisk(adoc.model) + count := len(stillAtRisk) + if count == 0 { + writeLine(f, "After removal of risks with status _mitigated_ and _false positive_ "+ + "*"+strconv.Itoa(count)+" remain unmitigated*.") + } else { + writeLine(f, "After removal of risks with status _mitigated_ and _false positive_ "+ + "the following *"+strconv.Itoa(count)+" remain unmitigated*:") + + countCritical := len(types.ReduceToOnlyStillAtRisk(filteredBySeverity(adoc.model, types.CriticalSeverity))) + countHigh := len(types.ReduceToOnlyStillAtRisk(filteredBySeverity(adoc.model, types.HighSeverity))) + countElevated := len(types.ReduceToOnlyStillAtRisk(filteredBySeverity(adoc.model, types.ElevatedSeverity))) + countMedium := len(types.ReduceToOnlyStillAtRisk(filteredBySeverity(adoc.model, types.MediumSeverity))) + countLow := len(types.ReduceToOnlyStillAtRisk(filteredBySeverity(adoc.model, types.LowSeverity))) + + countBusinessSide := len(types.ReduceToOnlyStillAtRisk(filteredByRiskFunction(adoc.model, types.BusinessSide))) + countArchitecture := len(types.ReduceToOnlyStillAtRisk(filteredByRiskFunction(adoc.model, types.Architecture))) + countDevelopment := len(types.ReduceToOnlyStillAtRisk(filteredByRiskFunction(adoc.model, types.Development))) + countOperation := len(types.ReduceToOnlyStillAtRisk(filteredByRiskFunction(adoc.model, types.Operations))) + + pieCharts := `[cols="a,a",frame=none,grid=none] +|=== +| +[mermaid] +.... +%%{init: {'pie' : {'textPosition' : 0.5}, 'theme': 'base', 'themeVariables': { 'pie1': '` + rgbHexColorCriticalRisk() + `', 'pie2': '` + rgbHexColorHighRisk() + `', 'pie3': '` + rgbHexColorElevatedRisk() + `', 'pie4': '` + rgbHexColorMediumRisk() + `', 'pie5': '` + rgbHexColorLowRisk() + `'}}}%% +pie showData + "unmitigated critical risk" : ` + strconv.Itoa(countCritical) + ` + "unmitigated high risk" : ` + strconv.Itoa(countHigh) + ` + "unmitigated elevated risk" : ` + strconv.Itoa(countElevated) + ` + "unmitigated medium risk" : ` + strconv.Itoa(countMedium) + ` + "unmitigated low risk" : ` + strconv.Itoa(countLow) + ` +.... + +| +[mermaid] +.... +%%{init: {'pie' : {'textPosition' : 0.5}, 'theme': 'base', 'themeVariables': { 'pie1': '` + rgbHexColorBusiness() + `', 'pie2': '` + rgbHexColorArchitecture() + `', 'pie3': '` + rgbHexColorDevelopment() + `', 'pie4': '` + rgbHexColorOperation() + `'}}}%% +pie showData + "business side related" : ` + strconv.Itoa(countBusinessSide) + ` + "architecture related" : ` + strconv.Itoa(countArchitecture) + ` + "development related" : ` + strconv.Itoa(countDevelopment) + ` + "operations related" : ` + strconv.Itoa(countOperation) + ` +.... +|=== +` + writeLine(f, pieCharts) + } +} + +func (adoc adocReport) writeRiskMitigationStatus() error { + filename := "03_RiskMitigationStatus.adoc" + rms, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.riskMitigationStatus(rms) + rms.Close() + return nil +} + +func (adoc adocReport) writeImpactRemainingRisks() error { + filename := "04_ImpactRemainingRisks.adoc" + irr, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.impactAnalysis(irr, false) + irr.Close() + return nil +} + +func addCustomImages(f *os.File, customImages []map[string]string, baseFolder string) { + for _, customImage := range customImages { + for imageFilename := range customImage { + imageFilenameWithoutPath := filepath.Base(imageFilename) + imageFullFilename := filepath.Join(baseFolder, imageFilenameWithoutPath) + writeLine(f, "image::"+imageFullFilename+"[]") + } + } +} + +func (adoc adocReport) targetDescription(f *os.File, baseFolder string) { + writeLine(f, "= Application Overview") + writeLine(f, "== Business Criticality\n") + writeLine(f, "The overall business criticality of \""+adoc.model.Title+"\" was rated as:\n") + + critString := "( " + criticality := adoc.model.BusinessCriticality + first := true + for _, critValue := range types.CriticalityValues() { + if !first { + critString += " |" + } + if critValue == criticality { + critString += " [.underline]#*" + strings.ToUpper(critValue.String()) + "*#" + } else { + critString += " [GreyText]#" + critValue.String() + "#" + } + first = false + } + critString += " )" + writeLine(f, critString) + + writeLine(f, "\n\n") + writeLine(f, "== Business Overview") + writeLine(f, fixBasicHtml(adoc.model.BusinessOverview.Description)) + addCustomImages(f, adoc.model.BusinessOverview.Images, baseFolder) + + writeLine(f, "\n\n") + writeLine(f, "== Technical Overview") + writeLine(f, fixBasicHtml(adoc.model.TechnicalOverview.Description)) + addCustomImages(f, adoc.model.TechnicalOverview.Images, baseFolder) +} + +func (adoc adocReport) writeTargetDescription(baseFolder string) error { + filename := "05_TargetDescription.adoc" + td, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.targetDescription(td, baseFolder) + td.Close() + return nil +} + +func (adoc adocReport) dataFlowDiagram(f *os.File, diagramFilenamePNG string) { + writeLine(f, "= Data-Flow Diagram") + // intermediate newlines are ignored in asciidoctor + writeLine(f, ` +The following diagram was generated by Threagile based on the model input and gives a high-level overview of the data-flow +between technical assets. The RAA value is the calculated _Relative Attacker Attractiveness_ in percent. +For a full high-resolution version of this diagram please refer to the PNG image file alongside this report. + `) + writeLine(f, "\nimage::"+diagramFilenamePNG+"[]") +} + +func imageIsWiderThanHigh(diagramFilenamePNG string) bool { + imagePath, _ := os.Open(diagramFilenamePNG) + defer func() { _ = imagePath.Close() }() + srcImage, _, _ := image.Decode(imagePath) + srcDimensions := srcImage.Bounds() + // wider than high? + muchWiderThanHigh := srcDimensions.Dx() > int(float64(srcDimensions.Dy())*1.25) + return muchWiderThanHigh +} + +func (adoc adocReport) writeDataFlowDiagram(diagramFilenamePNG string) error { + filename := "06_DataFlowDiagram.adoc" + dfd, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adocDfdFilename := filepath.Join(adoc.imagesDir, "data-flow-diagram.png") + copyFile(diagramFilenamePNG, adocDfdFilename) + + landScape := imageIsWiderThanHigh(adocDfdFilename) + if landScape { + adoc.writeMainLine("[page-layout=landscape]") + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.dataFlowDiagram(dfd, "images/data-flow-diagram.png") + if landScape { + adoc.writeMainLine("[page-layout=portrait]") + } + dfd.Close() + return nil +} + +func (adoc adocReport) securityRequirements(f *os.File) { + writeLine(f, "= Security Requirements") + writeLine(f, "This chapter lists the custom security requirements which have been defined for the modeled target.") + + writeLine(f, "\n") + for _, title := range sortedKeysOfSecurityRequirements(adoc.model) { + description := adoc.model.SecurityRequirements[title] + writeLine(f, title+"::") + writeLine(f, " "+description) + } + writeLine(f, "\n\n") + writeLine(f, "_This list is not complete and regulatory or law relevant security requirements have to be "+ + "taken into account as well. Also custom individual security requirements might exist for the project._") +} + +func (adoc adocReport) writeSecurityRequirements() error { + filename := "07_SecurityRequirements.adoc" + sr, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.securityRequirements(sr) + sr.Close() + return nil +} + +func (adoc adocReport) abuseCases(f *os.File) { + writeLine(f, "= Abuse Cases") + writeLine(f, "This chapter lists the custom abuse cases which have been defined for the modeled target.") + writeLine(f, "\n") + for _, title := range sortedKeysOfAbuseCases(adoc.model) { + description := adoc.model.AbuseCases[title] + writeLine(f, title+"::") + writeLine(f, " "+description) + } + writeLine(f, "\n\n") + writeLine(f, "_This list is not complete and regulatory or law relevant abuse cases have to be "+ + "taken into account as well. Also custom individual abuse cases might exist for the project._") +} + +func (adoc adocReport) writeAbuseCases() error { + filename := "08_AbuseCases.adoc" + ac, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.abuseCases(ac) + ac.Close() + return nil +} + +func (adoc adocReport) tagListing(f *os.File) { + writeLine(f, "= Tag Listing") + + writeLine(f, "This chapter lists what tags are used by which elements.") + writeLine(f, "\n") + sorted := adoc.model.TagsAvailable + sort.Strings(sorted) + for _, tag := range sorted { + description := "" // TODO: add some separation texts to distinguish between technical assets and data assets etc. for example? + for _, techAsset := range sortedTechnicalAssetsByTitle(adoc.model) { + if contains(techAsset.Tags, tag) { + if len(description) > 0 { + description += ", " + } + description += techAsset.Title + } + for _, commLink := range techAsset.CommunicationLinksSorted() { + if contains(commLink.Tags, tag) { + if len(description) > 0 { + description += ", " + } + description += commLink.Title + } + } + } + for _, dataAsset := range sortedDataAssetsByTitle(adoc.model) { + if contains(dataAsset.Tags, tag) { + if len(description) > 0 { + description += ", " + } + description += dataAsset.Title + } + } + for _, trustBoundary := range sortedTrustBoundariesByTitle(adoc.model) { + if contains(trustBoundary.Tags, tag) { + if len(description) > 0 { + description += ", " + } + description += trustBoundary.Title + } + } + for _, sharedRuntime := range sortedSharedRuntimesByTitle(adoc.model) { + if contains(sharedRuntime.Tags, tag) { + if len(description) > 0 { + description += ", " + } + description += sharedRuntime.Title + } + } + if len(description) > 0 { + writeLine(f, tag+"::") + writeLine(f, " "+description) + } + } +} + +func (adoc adocReport) writeTagListing() error { + filename := "09_TagListing.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.tagListing(f) + f.Close() + return nil +} + +func (adoc adocReport) stride(f *os.File) { + writeLine(f, "= STRIDE Classification of Identified Risks") + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + writeLine(f, "") + + risksSTRIDESpoofing := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Spoofing) + risksSTRIDETampering := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Tampering) + risksSTRIDERepudiation := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Repudiation) + risksSTRIDEInformationDisclosure := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.InformationDisclosure) + risksSTRIDEDenialOfService := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.DenialOfService) + risksSTRIDEElevationOfPrivilege := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.ElevationOfPrivilege) + + countSTRIDESpoofing := countRisks(risksSTRIDESpoofing) + countSTRIDETampering := countRisks(risksSTRIDETampering) + countSTRIDERepudiation := countRisks(risksSTRIDERepudiation) + countSTRIDEInformationDisclosure := countRisks(risksSTRIDEInformationDisclosure) + countSTRIDEDenialOfService := countRisks(risksSTRIDEDenialOfService) + countSTRIDEElevationOfPrivilege := countRisks(risksSTRIDEElevationOfPrivilege) + + writeLine(f, "This chapter clusters and classifies the risks by STRIDE categories: "+ + "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" potential risks* have been identified during the threat modeling process "+ + "of which *"+strconv.Itoa(countSTRIDESpoofing)+" in the "+types.Spoofing.Title()+"* category, "+ + "*"+strconv.Itoa(countSTRIDETampering)+" in the "+types.Tampering.Title()+"* category, "+ + "*"+strconv.Itoa(countSTRIDERepudiation)+" in the "+types.Repudiation.Title()+"* category, "+ + "*"+strconv.Itoa(countSTRIDEInformationDisclosure)+" in the "+types.InformationDisclosure.Title()+"* category, "+ + "*"+strconv.Itoa(countSTRIDEDenialOfService)+" in the "+types.DenialOfService.Title()+"* category, "+ + "and *"+strconv.Itoa(countSTRIDEElevationOfPrivilege)+" in the "+types.ElevationOfPrivilege.Title()+"* category.{fn-risk-findings}") + writeLine(f, "") + + reverseRiskSeverity := []types.RiskSeverity{ + types.CriticalSeverity, + types.HighSeverity, + types.ElevatedSeverity, + types.MediumSeverity, + types.LowSeverity, + } + strides := []types.STRIDE{ + types.Spoofing, + types.Tampering, + types.Repudiation, + types.InformationDisclosure, + types.DenialOfService, + types.ElevationOfPrivilege, + } + + for _, strideValue := range strides { + writeLine(f, "== "+strideValue.Title()) + risksSTRIDE := reduceToSTRIDERisk(adoc.model, adoc.model.GeneratedRisksByCategory, strideValue) + for _, critValue := range reverseRiskSeverity { + adoc.addCategories(f, risksSTRIDE, true, critValue, true, true) + } + writeLine(f, "") + } +} + +func (adoc adocReport) writeSTRIDE() error { + filename := "10_STRIDE.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.stride(f) + f.Close() + return nil +} + +func (adoc adocReport) assignmentByFunction(f *os.File) { + writeLine(f, "= Assignment by Function") + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + writeLine(f, "") + + risksBusinessSideFunction := reduceToFunctionRisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.BusinessSide) + risksArchitectureFunction := reduceToFunctionRisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Architecture) + risksDevelopmentFunction := reduceToFunctionRisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Development) + risksOperationFunction := reduceToFunctionRisk(adoc.model, adoc.model.GeneratedRisksByCategory, types.Operations) + + countBusinessSideFunction := countRisks(risksBusinessSideFunction) + countArchitectureFunction := countRisks(risksArchitectureFunction) + countDevelopmentFunction := countRisks(risksDevelopmentFunction) + countOperationFunction := countRisks(risksOperationFunction) + writeLine(f, "This chapter clusters and assigns the risks by functions which are most likely able to "+ + "check and mitigate them: "+ + "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" potential risks* have been identified during the threat modeling process "+ + "of which *"+strconv.Itoa(countBusinessSideFunction)+" should be checked by "+types.BusinessSide.Title()+"*, "+ + "*"+strconv.Itoa(countArchitectureFunction)+" should be checked by "+types.Architecture.Title()+"*, "+ + "*"+strconv.Itoa(countDevelopmentFunction)+" should be checked by "+types.Development.Title()+"*, "+ + "and *"+strconv.Itoa(countOperationFunction)+" should be checked by "+types.Operations.Title()+"*.{fn-risk-findings}") + writeLine(f, "") + + riskFunctionValues := []types.RiskFunction{ + types.BusinessSide, + types.Architecture, + types.Development, + types.Operations, + } + reverseRiskSeverity := []types.RiskSeverity{ + types.CriticalSeverity, + types.HighSeverity, + types.ElevatedSeverity, + types.MediumSeverity, + types.LowSeverity, + } + + for _, riskFunctionValue := range riskFunctionValues { + writeLine(f, "== "+riskFunctionValue.Title()) + risksFunction := reduceToFunctionRisk(adoc.model, adoc.model.GeneratedRisksByCategory, riskFunctionValue) + for _, critValue := range reverseRiskSeverity { + adoc.addCategories(f, risksFunction, true, critValue, true, false) + } + writeLine(f, "") + } +} + +func (adoc adocReport) writeAssignmentByFunction() error { + filename := "11_AssignmentByFunction.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.assignmentByFunction(f) + f.Close() + return nil +} + +func (adoc adocReport) raa(f *os.File, introTextRAA string) { + writeLine(f, "= RAA Analysis") + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + writeLine(f, "") + writeLine(f, fixBasicHtml(introTextRAA)+"{fn-risk-findings}") + writeLine(f, "") + + for _, technicalAsset := range sortedTechnicalAssetsByRAAAndTitle(adoc.model) { + if technicalAsset.OutOfScope { + continue + } + newRisksStr := adoc.model.GeneratedRisks(technicalAsset) + colorPrefix := "" + switch types.HighestSeverityStillAtRisk(newRisksStr) { + case types.HighSeverity: + colorPrefix = "[HighRisk]#" + case types.MediumSeverity: + colorPrefix = "[MediumRisk]#" + case types.LowSeverity: + colorPrefix = "[LowRisk]#" + default: + colorPrefix = "" + } + if len(types.ReduceToOnlyStillAtRisk(newRisksStr)) == 0 { + colorPrefix = "" + } + + fullLine := "<<" + technicalAsset.Id + "," + colorPrefix + "*" + technicalAsset.Title + "*" + if technicalAsset.OutOfScope { + fullLine += ": out-of-scope" + } else { + fullLine += ": RAA " + fmt.Sprintf("%.0f", technicalAsset.RAA) + "%" + } + if len(colorPrefix) > 0 { + fullLine += "#" + } + writeLine(f, fullLine+">>::") + writeLine(f, " "+technicalAsset.Description) + } +} + +func (adoc adocReport) writeRAA(introTextRAA string) error { + filename := "12_RAA.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.raa(f, introTextRAA) + f.Close() + return nil +} + +func (adoc adocReport) dataRiskMapping(f *os.File, diagramFilenamePNG string) { + writeLine(f, "= Data Mapping") + + writeLine(f, ` +The following diagram was generated by Threagile based on the model input and gives a high-level distribution of +data assets across technical assets. The color matches the identified data breach probability and risk level (see +the "Data Breach Probabilities" chapter for more details). A solid line stands for _data is stored by the asset_ +and a dashed one means _data is processed by the asset_. For a full high-resolution version of this diagram please +refer to the PNG image file alongside this report.`) + writeLine(f, "\nimage::"+diagramFilenamePNG+"[]") +} + +func (adoc adocReport) writeDataRiskMapping(dataAssetDiagramFilenamePNG string) error { + filename := "13_DataRiskMapping.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adocDataRiskMappingFilename := filepath.Join(adoc.imagesDir, "data-asset-diagram.png") + copyFile(dataAssetDiagramFilenamePNG, adocDataRiskMappingFilename) + + landScape := imageIsWiderThanHigh(adocDataRiskMappingFilename) + if landScape { + adoc.writeMainLine("[page-layout=landscape]") + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.dataRiskMapping(f, "images/data-asset-diagram.png") + if landScape { + adoc.writeMainLine("[page-layout=portrait]") + } + f.Close() + return nil +} + +func (adoc adocReport) outOfScopeAssets(f *os.File) { + assets := "Asset" + count := len(adoc.model.OutOfScopeTechnicalAssets()) + if count > 1 { + assets += "s" + } + writeLine(f, "= Out-of-Scope Assets: "+strconv.Itoa(count)+" "+assets) + writeLine(f, ":fn-tech-assets: footnote:techAssets[Technical asset paragraphs are clickable and link to the corresponding chapter.]") + writeLine(f, "") + writeLine(f, ` +This chapter lists all technical assets that have been defined as out-of-scope. +Each one should be checked in the model whether it should better be included in the overall risk analysis{fn-tech-assets}: +`) + writeLine(f, "") + + outOfScopeAssetCount := 0 + for _, technicalAsset := range sortedTechnicalAssetsByRAAAndTitle(adoc.model) { + if technicalAsset.OutOfScope { + outOfScopeAssetCount++ + writeLine(f, "<<"+technicalAsset.Id+",[OutOfScope]#"+technicalAsset.Title+" : out-of-scope#>>::") + writeLine(f, " "+technicalAsset.JustificationOutOfScope) + } + } + + if outOfScopeAssetCount == 0 { + writeLine(f, "[GreyText]#No technical assets have been defined as out-of-scope.#") + } +} + +func (adoc adocReport) writeOutOfScopeAssets() error { + filename := "14_OutOfScopeAssets.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.outOfScopeAssets(f) + f.Close() + return nil +} + +func (adoc adocReport) modelFailures(f *os.File) { + modelFailures := flattenRiskSlice(filterByModelFailures(adoc.model, adoc.model.GeneratedRisksByCategory)) + risksStr := "Risk" + count := len(modelFailures) + if count > 1 { + risksStr += "s" + } + countStillAtRisk := len(types.ReduceToOnlyStillAtRisk(modelFailures)) + colorPrefix := "" + colorSuffix := "" + if countStillAtRisk > 0 { + colorPrefix = "[ModelFailure]#" + colorSuffix = "#" + } + writeLine(f, "= "+colorPrefix+"Potential Model Failures: "+strconv.Itoa(countStillAtRisk)+" / "+strconv.Itoa(count)+" "+risksStr+colorSuffix) + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + writeLine(f, "") + + writeLine(f, ` +This chapter lists potential model failures where not all relevant assets have been +modeled or the model might itself contain inconsistencies. Each potential model failure should be checked +in the model against the architecture design:{fn-risk-findings}`) + writeLine(f, "") + + modelFailuresByCategory := filterByModelFailures(adoc.model, adoc.model.GeneratedRisksByCategory) + if len(modelFailuresByCategory) == 0 { + writeLine(f, "No potential model failures have been identified.") + } else { + adoc.addCategories(f, modelFailuresByCategory, true, types.CriticalSeverity, true, true) + adoc.addCategories(f, modelFailuresByCategory, true, types.HighSeverity, true, true) + adoc.addCategories(f, modelFailuresByCategory, true, types.ElevatedSeverity, true, true) + adoc.addCategories(f, modelFailuresByCategory, true, types.MediumSeverity, true, true) + adoc.addCategories(f, modelFailuresByCategory, true, types.LowSeverity, true, true) + } +} + +func (adoc adocReport) writeModelFailures() error { + filename := "15_ModelFailures.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.modelFailures(f) + f.Close() + return nil +} + +func (adoc adocReport) questions(f *os.File) { + questions := "Question" + count := len(adoc.model.Questions) + if count > 1 { + questions += "s" + } + colorPrefix := "" + colorSuffix := "" + if questionsUnanswered(adoc.model) > 0 { + colorPrefix = "[ModelFailure]#" + colorSuffix = "#" + } + writeLine(f, "= "+colorPrefix+"Questions: "+strconv.Itoa(questionsUnanswered(adoc.model))+" / "+strconv.Itoa(count)+" "+questions+colorSuffix) + writeLine(f, "") + writeLine(f, "This chapter lists custom questions that arose during the threat modeling process.") + writeLine(f, "") + + if len(adoc.model.Questions) == 0 { + writeLine(f, "") + writeLine(f, "[GreyText]#No custom questions arose during the threat modeling process.#") + } + writeLine(f, "") + + for _, question := range sortedKeysOfQuestions(adoc.model) { + answer := adoc.model.Questions[question] + if len(strings.TrimSpace(answer)) > 0 { + writeLine(f, "*"+question+"*::") + writeLine(f, "_"+strings.TrimSpace(answer)+"_") + } else { + writeLine(f, "*[ModelFailure]#"+question+"#*::") + writeLine(f, "[GreyText]#_- answer pending -_#") + } + } +} + +func (adoc adocReport) writeQuestions() error { + filename := "16_Questions.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.questions(f) + f.Close() + return nil +} + +func (adoc adocReport) riskTrackingStatus(f *os.File, risk *types.Risk) { + tracking := adoc.model.GetRiskTrackingWithDefault(risk) + + colorName := "" + switch tracking.Status { + case types.Unchecked: + colorName = "RiskStatusUnchecked" + case types.InDiscussion: + colorName = "RiskStatusInDiscussion" + case types.Accepted: + colorName = "RiskStatusAccepted" + case types.InProgress: + colorName = "RiskStatusInProgress" + case types.Mitigated: + colorName = "RiskStatusMitigated" + case types.FalsePositive: + colorName = "RiskStatusFalsePositive" + default: + colorName = "" + } + bold := "" + if tracking.Status == types.Unchecked { + bold = "*" + } + + if tracking.Status != types.Unchecked { + dateStr := tracking.Date.Format("2006-01-02") + if dateStr == "0001-01-01" { + dateStr = "" + } + justificationStr := tracking.Justification + writeLine(f, ` +[cols="a,c,c,c",frame=none,grid=none,options="unbreakable"] +|=== +| [.`+colorName+`.small]#`+bold+tracking.Status.Title()+bold+`# +| [.GreyText.small]#`+dateStr+`# +| [.GreyText.small]#`+tracking.CheckedBy+`# +| [.GreyText.small]#`+tracking.Ticket+`# + +4+|[.small]#`+justificationStr+`# +|=== +`) + } else { + writeLine(f, ` +[cols="a,c,c,c",frame=none,grid=none,options="unbreakable"] +|=== +4+| [.`+colorName+`.small]#`+bold+tracking.Status.Title()+bold+`# +|=== +`) + } +} + +func (adoc adocReport) riskCategories(f *os.File) { + writeLine(f, "= Identified Risks by Vulnerability category") + writeLine(f, "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" potential risks* have been identified during the threat modeling process "+ + "of which "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.CriticalSeverity)))+" are rated as critical*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.HighSeverity)))+" as high*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.ElevatedSeverity)))+" as elevated*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.MediumSeverity)))+" as medium*, "+ + "and *"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.LowSeverity)))+" as low*. "+ + "\n\nThese risks are distributed across *"+strconv.Itoa(len(adoc.model.GeneratedRisksByCategory))+" vulnerability categories*. ") + writeLine(f, "The following sub-chapters of this section describe each identified risk category.") // TODO more explanation text + writeLine(f, "") + + for _, category := range adoc.model.SortedRiskCategories() { + risksStr := adoc.model.SortedRisksOfCategory(category) + + // category color + colorPrefix, colorSuffix := colorPrefixBySeverity(types.HighestSeverityStillAtRisk(risksStr), false) + if len(types.ReduceToOnlyStillAtRisk(risksStr)) == 0 { + colorPrefix = "" + colorSuffix = "" + } + + // category title + countStillAtRisk := len(types.ReduceToOnlyStillAtRisk(risksStr)) + suffix := strconv.Itoa(countStillAtRisk) + " / " + strconv.Itoa(len(risksStr)) + " Risk" + if len(risksStr) != 1 { + suffix += "s" + } + title := colorPrefix + category.Title + ": " + suffix + colorSuffix + writeLine(f, "[["+category.ID+"]]") + writeLine(f, "== "+title) + writeLine(f, "") + + // category details + cweLink := "n/a" + if category.CWE > 0 { + cweLink = "https://cwe.mitre.org/data/definitions/" + strconv.Itoa(category.CWE) + ".html[CWE " + + strconv.Itoa(category.CWE) + "]" + } + writeLine(f, "*Description* ("+category.STRIDE.Title()+"): "+cweLink+"::") + writeLine(f, fixBasicHtml(category.Description)) + writeLine(f, "*Impact*::") + writeLine(f, fixBasicHtml(category.Impact)) + writeLine(f, "*Detection Logic*::") + writeLine(f, fixBasicHtml(category.DetectionLogic)) + writeLine(f, "*Risk Rating*::") + writeLine(f, fixBasicHtml(category.RiskAssessment)) + + writeLine(f, "[RiskStatusFalsePositive]#*False Positives*#::") + writeLine(f, "[RiskStatusFalsePositive]#"+category.FalsePositives+"#") + + writeLine(f, "[RiskStatusMitigated]#*Mitigation*# ("+category.Function.Title()+"): "+category.Action+"::") + writeLine(f, "[RiskStatusMitigated]#"+fixBasicHtml(category.Mitigation)+"#") + + asvsChapter := category.ASVS + asvsLink := "n/a" + if len(asvsChapter) > 0 { + asvsLink = "https://owasp.org/www-project-application-security-verification-standard/[" + asvsChapter + "]" + } + + cheatSheetLink := category.CheatSheet + if len(cheatSheetLink) == 0 { + cheatSheetLink = "n/a" + } else { + lastLinkParts := strings.Split(cheatSheetLink, "/") + linkText := lastLinkParts[len(lastLinkParts)-1] + if strings.HasSuffix(linkText, ".html") || strings.HasSuffix(linkText, ".htm") { + var extension = filepath.Ext(linkText) + linkText = linkText[0 : len(linkText)-len(extension)] + } + cheatSheetLink = cheatSheetLink + "[" + linkText + "]" + } + writeLine(f, "") + writeLine(f, "* [RiskStatusMitigated]#ASVS Chapter#: "+asvsLink) + writeLine(f, "* [RiskStatusMitigated]#Cheat Sheet#: "+cheatSheetLink) + writeLine(f, "\n\n*Check*\n") + writeLine(f, category.Check) + + // risk details + writeLine(f, "") + writeLine(f, "<<<") + writeLine(f, "=== Risk Findings") + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") + times := strconv.Itoa(len(risksStr)) + " time" + if len(risksStr) > 1 { + times += "s" + } + writeLine(f, "") + writeLine(f, "The risk *"+category.Title+"* was found *"+times+"* in the analyzed architecture to be "+ + "potentially possible. Each spot should be checked individually by reviewing the implementation whether all "+ + "controls have been applied properly in order to mitigate each risk.{fn-risk-findings}") + + for _, risk := range risksStr { + colorPrefix, colorSuffix := colorPrefixBySeverity(risk.Severity, false) + if len(colorPrefix) == 0 { + colorSuffix = "" + } + + title := titleOfSeverity(risk.Severity) + if len(title) > 0 { + writeLine(f, "") + writeLine(f, "==== "+colorPrefix+"_"+title+"_"+colorSuffix) + } + + if !risk.RiskStatus.IsStillAtRisk() { + colorPrefix = "" + colorSuffix = "" + } + writeLine(f, colorPrefix+fixBasicHtml(risk.Title)+": Exploitation likelihood is _"+risk.ExploitationLikelihood.Title()+"_ with _"+risk.ExploitationImpact.Title()+"_ impact."+colorSuffix) + linkId := "" + if len(risk.MostRelevantSharedRuntimeId) > 0 { + linkId = risk.MostRelevantSharedRuntimeId + } else if len(risk.MostRelevantTrustBoundaryId) > 0 { + linkId = risk.MostRelevantTrustBoundaryId + } else if len(risk.MostRelevantTechnicalAssetId) > 0 { + linkId = risk.MostRelevantTechnicalAssetId + } + writeLine(f, "") + writeLine(f, "<<"+linkId+",[SmallGrey]#"+risk.SyntheticId+"#>>") + + adoc.riskTrackingStatus(f, risk) + } + } +} + +func (adoc adocReport) writeRiskCategories() error { + filename := "17_RiskCategories.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.riskCategories(f) + f.Close() + return nil +} + +func joinedOrNoneString(strs []string, noneValue string) string { + if noneValue == "" { + noneValue = "[GrayText]#none#" + } + sort.Strings(strs) + singleLine := strings.Join(strs[:], ", ") + if len(singleLine) == 0 { + singleLine = noneValue + } + return singleLine +} + +func dataAssetListTitleJoinOrNone(assets []*types.DataAsset, noneValue string) string { + var dataAssetTitles []string + for _, dataAsset := range assets { + dataAssetTitles = append(dataAssetTitles, dataAsset.Title) + } + return joinedOrNoneString(dataAssetTitles, noneValue) +} + +func technicalAssetTitleOrNone(links []*types.TechnicalAsset, noneValue string) string { + var titles []string + for _, asset := range links { + titles = append(titles, asset.Title) + } + return joinedOrNoneString(titles, noneValue) +} + +func dataFormatTitleJoinOrNone(assets []types.DataFormat, noneValue string) string { + var dataAssetTitles []string + for _, dataFormat := range assets { + dataAssetTitles = append(dataAssetTitles, dataFormat.Title()) + } + return joinedOrNoneString(dataAssetTitles, noneValue) +} + +func communicationLinkTitleOrNone(links []*types.CommunicationLink, noneValue string) string { + var titles []string + for _, link := range links { + titles = append(titles, link.Title) + } + return joinedOrNoneString(titles, noneValue) +} + +func (adoc adocReport) technicalAssets(f *os.File) { + writeLine(f, "= Identified Risks by Technical Asset") + writeLine(f, "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" potential risks* have been identified during the threat modeling process "+ + "of which "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.CriticalSeverity)))+" are rated as critical*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.HighSeverity)))+" as high*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.ElevatedSeverity)))+" as elevated*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.MediumSeverity)))+" as medium*, "+ + "and *"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.LowSeverity)))+" as low*. "+ + "\n\nThese risks are distributed across *"+strconv.Itoa(len(adoc.model.InScopeTechnicalAssets()))+" in-scope technical assets*. ") + writeLine(f, "The following sub-chapters of this section describe each identified risk grouped by technical asset. ") // TODO more explanation text + writeLine(f, "The RAA value of a technical asset is the calculated \"Relative Attacker Attractiveness\" value in percent.") + + for _, technicalAsset := range sortedTechnicalAssetsByRiskSeverityAndTitle(adoc.model) { + risksStr := adoc.model.GeneratedRisks(technicalAsset) + countStillAtRisk := len(types.ReduceToOnlyStillAtRisk(risksStr)) + suffix := strconv.Itoa(countStillAtRisk) + " / " + strconv.Itoa(len(risksStr)) + " Risk" + if len(risksStr) != 1 { + suffix += "s" + } + colorPrefix, colorSuffix := colorPrefixBySeverity(types.HighestSeverityStillAtRisk(risksStr), false) + if technicalAsset.OutOfScope { + colorPrefix = "[OutOfScope]#" + suffix = "out-of-scope" + } else { + if len(types.ReduceToOnlyStillAtRisk(risksStr)) == 0 { + colorPrefix = "" + colorSuffix = "" + } + } + + // asset title + title := colorPrefix + technicalAsset.Title + ": " + suffix + colorSuffix + writeLine(f, "[["+technicalAsset.Id+"]]") + writeLine(f, "== "+title) + + // asset description + writeLine(f, "=== Description") + writeLine(f, technicalAsset.Description) + writeLine(f, "") + + // and more metadata of asset in tabular view + writeLine(f, "=== Identified Risks of Asset") + if len(risksStr) > 0 { + writeLine(f, "[GreyText]#Risk finding paragraphs are clickable and link to the corresponding chapter.#") + for _, risk := range risksStr { + colorPrefix, colorSuffix = colorPrefixBySeverity(types.HighestSeverityStillAtRisk(risksStr), false) + if !risk.RiskStatus.IsStillAtRisk() { + colorPrefix = "" + colorSuffix = "" + } + writeLine(f, "\n==== "+colorPrefix+titleOfSeverity(risk.Severity)+colorSuffix+"\n") + writeLine(f, colorPrefix+fixBasicHtml(risk.Title)+": Exploitation likelihood is _"+risk.ExploitationLikelihood.Title()+"_ with _"+risk.ExploitationImpact.Title()+"_ impact."+colorSuffix) + writeLine(f, "") + + writeLine(f, "<<"+risk.CategoryId+",[SmallGrey]#"+risk.SyntheticId+"#>>") + adoc.riskTrackingStatus(f, risk) + } + } else { + text := "No risksStr were identified." + if technicalAsset.OutOfScope { + text = "Asset was defined as out-of-scope." + } + writeLine(f, "[GrayText]#"+text+"#") + } + + // ASSET INFORMATION + writeLine(f, "<<<") + writeLine(f, "=== Asset Information") + textRAA := fmt.Sprintf("%.0f", technicalAsset.RAA) + " %" + if technicalAsset.OutOfScope { + textRAA = "[GrayText]#out-of-scope#" + } + + tagsUsedText := joinedOrNoneString(technicalAsset.Tags, "") + dataAssetsProcessedText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsProcessedSorted(technicalAsset), "") + dataAssetsStoredText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsStoredSorted(technicalAsset), "") + formatsAcceptedText := dataFormatTitleJoinOrNone(technicalAsset.DataFormatsAcceptedSorted(), "[GrayText]#none of the special data formats accepted#") + + writeLine(f, ` +[cols="h,5",frame=none,grid=none] +|=== +| ID: | `+technicalAsset.Id+` +| Type: | `+technicalAsset.Type.String()+` +| Usage: | `+technicalAsset.Usage.String()+` +| RAA: | `+textRAA+` +| Size: | `+technicalAsset.Size.String()+` +| Technology: | `+technicalAsset.Technologies.String()+` +| Tags: | `+tagsUsedText+` +| Internet: | `+strconv.FormatBool(technicalAsset.Internet)+` +| Machine: | `+technicalAsset.Machine.String()+` +| Encryption: | `+technicalAsset.Encryption.String()+` +| Encryption: | `+technicalAsset.Encryption.String()+` +| Multi-Tenant: | `+strconv.FormatBool(technicalAsset.MultiTenant)+` +| Redundant: | `+strconv.FormatBool(technicalAsset.Redundant)+` +| Custom-Developed: | `+strconv.FormatBool(technicalAsset.CustomDevelopedParts)+` +| Client by Human: | `+strconv.FormatBool(technicalAsset.UsedAsClientByHuman)+` +| Data Processed: | `+dataAssetsProcessedText+` +| Data Stored: | `+dataAssetsStoredText+` +| Formats Accepted: | `+formatsAcceptedText+` +|=== +`) + + writeLine(f, "=== Asset Rating") + writeLine(f, ` +[cols="h,2,1",frame=none,grid=none] +|=== +| Owner: 2+| `+technicalAsset.Owner+` +| Confidentiality: | `+technicalAsset.Confidentiality.String()+` | `+technicalAsset.Confidentiality.RatingStringInScale()+` +| Integrity: | `+technicalAsset.Integrity.String()+` | `+technicalAsset.Integrity.RatingStringInScale()+` +| Availability: | `+technicalAsset.Availability.String()+` | `+technicalAsset.Availability.RatingStringInScale()+` +| CIA-Justification: 2+| `+technicalAsset.JustificationCiaRating) + if technicalAsset.OutOfScope { + writeLine(f, "| Asset Out-of-Scope Justification: 2+| "+technicalAsset.JustificationOutOfScope) + } + writeLine(f, "|===\n") + + if len(technicalAsset.CommunicationLinks) > 0 { + writeLine(f, "=== Outgoing Communication Links: "+strconv.Itoa(len(technicalAsset.CommunicationLinks))) + for _, outgoingCommLink := range technicalAsset.CommunicationLinksSorted() { + writeLine(f, "==== "+outgoingCommLink.Title+" (outgoing)") + writeLine(f, fixBasicHtml(outgoingCommLink.Description)) + + tagsUsedText := joinedOrNoneString(outgoingCommLink.Tags, "") + dataAssetsSentText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsSentSorted(outgoingCommLink), "") + dataAssetsReceivedText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsReceivedSorted(outgoingCommLink), "") + + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| Target: | <<`+outgoingCommLink.TargetId+`,`+adoc.model.TechnicalAssets[outgoingCommLink.TargetId].Title+`>> +| Protocol: | `+outgoingCommLink.Protocol.String()+` +| Encrypted: | `+strconv.FormatBool(outgoingCommLink.Protocol.IsEncrypted())+` +| Authentication: | `+outgoingCommLink.Authentication.String()+` +| Authorization: | `+outgoingCommLink.Authorization.String()+` +| Read-Only: | `+strconv.FormatBool(outgoingCommLink.Readonly)+` +| Usage: | `+outgoingCommLink.Usage.String()+` +| Tags: | `+tagsUsedText+` +| VPN: | `+strconv.FormatBool(outgoingCommLink.VPN)+` +| IP-Filtered: | `+strconv.FormatBool(outgoingCommLink.IpFiltered)+` +| Data Sent: | `+dataAssetsSentText+` +| Data Received: | `+dataAssetsReceivedText+` +|=== +`) + } + } + + incomingCommLinks := adoc.model.IncomingTechnicalCommunicationLinksMappedByTargetId[technicalAsset.Id] + if len(incomingCommLinks) > 0 { + writeLine(f, "=== Incoming Communication Links: "+strconv.Itoa(len(incomingCommLinks))) + for _, incomingCommLink := range incomingCommLinks { + writeLine(f, "==== "+incomingCommLink.Title+" (outgoing)") + writeLine(f, fixBasicHtml(incomingCommLink.Description)) + + tagsUsedText := joinedOrNoneString(incomingCommLink.Tags, "") + dataAssetsSentText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsSentSorted(incomingCommLink), "") + dataAssetsReceivedText := dataAssetListTitleJoinOrNone(adoc.model.DataAssetsReceivedSorted(incomingCommLink), "") + + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| Source: | <<`+incomingCommLink.SourceId+`,`+adoc.model.TechnicalAssets[incomingCommLink.SourceId].Title+`>> +| Protocol: | `+incomingCommLink.Protocol.String()+` +| Encrypted: | `+strconv.FormatBool(incomingCommLink.Protocol.IsEncrypted())+` +| Authentication: | `+incomingCommLink.Authentication.String()+` +| Authorization: | `+incomingCommLink.Authorization.String()+` +| Read-Only: | `+strconv.FormatBool(incomingCommLink.Readonly)+` +| Usage: | `+incomingCommLink.Usage.String()+` +| Tags: | `+tagsUsedText+` +| VPN: | `+strconv.FormatBool(incomingCommLink.VPN)+` +| IP-Filtered: | `+strconv.FormatBool(incomingCommLink.IpFiltered)+` +| Data Sent: | `+dataAssetsSentText+` +| Data Received: | `+dataAssetsReceivedText+` +|=== +`) + } + } + } +} + +func (adoc adocReport) writeTechnicalAssets() error { + filename := "18_TechnicalAssets.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.technicalAssets(f) + f.Close() + return nil +} + +func (adoc adocReport) dataAssets(f *os.File) { + writeLine(f, "= Identified Data Breach Probabilities by Data Asset") + writeLine(f, "In total *"+strconv.Itoa(totalRiskCount(adoc.model))+" potential risks* have been identified during the threat modeling process "+ + "of which "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.CriticalSeverity)))+" are rated as critical*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.HighSeverity)))+" as high*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.ElevatedSeverity)))+" as elevated*, "+ + "*"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.MediumSeverity)))+" as medium*, "+ + "and *"+strconv.Itoa(len(filteredBySeverity(adoc.model, types.LowSeverity)))+" as low*. "+ + "\n\nThese risks are distributed across *"+strconv.Itoa(len(adoc.model.DataAssets))+" data assets*. ") + writeLine(f, "The following sub-chapters of this section describe the derived data breach probabilities grouped by data asset.") // TODO more explanation text + writeLine(f, "") + for _, dataAsset := range sortedDataAssetsByDataBreachProbabilityAndTitle(adoc.model) { + + dataBreachProbability := identifiedDataBreachProbabilityStillAtRisk(adoc.model, dataAsset) + colorPrefix, colorSuffix := colorPrefixByDataBreachProbability(dataBreachProbability, false) + if !isDataBreachPotentialStillAtRisk(adoc.model, dataAsset) { + colorPrefix = "" + } + risksStr := adoc.model.IdentifiedDataBreachProbabilityRisks(dataAsset) + countStillAtRisk := len(types.ReduceToOnlyStillAtRisk(risksStr)) + suffix := strconv.Itoa(countStillAtRisk) + " / " + strconv.Itoa(len(risksStr)) + " Risk" + if len(risksStr) != 1 { + suffix += "s" + } + writeLine(f, "<<<") + writeLine(f, "== "+colorPrefix+dataAsset.Title+": "+suffix+colorSuffix) + writeLine(f, fixBasicHtml(dataAsset.Description)+"\n\n") + + tagsUsedText := joinedOrNoneString(dataAsset.Tags, "") + processedByText := technicalAssetTitleOrNone(adoc.model.ProcessedByTechnicalAssetsSorted(dataAsset), "") + storedByText := technicalAssetTitleOrNone(adoc.model.StoredByTechnicalAssetsSorted(dataAsset), "") + sentViaText := communicationLinkTitleOrNone(adoc.model.SentViaCommLinksSorted(dataAsset), "") + receivedViaText := communicationLinkTitleOrNone(adoc.model.ReceivedViaCommLinksSorted(dataAsset), "") + dataBreachRisksStillAtRisk := identifiedDataBreachProbabilityRisksStillAtRisk(adoc.model, dataAsset) + sortByDataBreachProbability(dataBreachRisksStillAtRisk, adoc.model) + dataBreachText := "This data asset has no data breach potential." + if len(dataBreachRisksStillAtRisk) > 0 { + riskRemainingStr := "risk" + if countStillAtRisk > 1 { + riskRemainingStr += "s" + } + dataBreachText = "This data asset has data breach potential because of " + + "" + strconv.Itoa(countStillAtRisk) + " remaining " + riskRemainingStr + ":" + } + + riskText := dataBreachProbability.String() + if !isDataBreachPotentialStillAtRisk(adoc.model, dataAsset) { + colorPrefix = "" + colorSuffix = "" + riskText = "none" + } + + writeLine(f, ` +[cols="h,2,1",frame=none,grid=none] +|=== +| ID: 2+| `+dataAsset.Id+` +| Usage: 2+| `+dataAsset.Usage.String()+` +| Quantity: 2+| `+dataAsset.Quantity.String()+` +| Tags: 2+| `+tagsUsedText+` +| Origin: 2+| `+dataAsset.Origin+` +| Owner: 2+| `+dataAsset.Owner+` +| Confidentiality: | `+dataAsset.Confidentiality.String()+` | `+dataAsset.Confidentiality.RatingStringInScale()+` +| Integrity: | `+dataAsset.Integrity.String()+` | `+dataAsset.Integrity.RatingStringInScale()+` +| Availability: | `+dataAsset.Availability.String()+` | `+dataAsset.Availability.RatingStringInScale()+` +| CIA-Justification: 2+| `+dataAsset.JustificationCiaRating+` +| Processed by: 2+| `+processedByText+` +| Stored by: 2+| `+storedByText+` +| Sent via: 2+| `+sentViaText+` +| Received via: 2+| `+receivedViaText+` +| Data Breach: 2+| `+colorPrefix+riskText+colorSuffix+` +| Data Breach Risks: 2+| `+dataBreachText) + + if len(dataBreachRisksStillAtRisk) > 0 { + for _, dataBreachRisk := range dataBreachRisksStillAtRisk { + colorPrefix, colorSuffix := colorPrefixByDataBreachProbability(dataBreachRisk.DataBreachProbability, true) + if !dataBreachRisk.RiskStatus.IsStillAtRisk() { + colorPrefix = "" + } + + txt := dataBreachRisk.DataBreachProbability.Title() + ": " + dataBreachRisk.SyntheticId + writeLine(f, "| 2+| <<"+dataBreachRisk.CategoryId+","+colorPrefix+txt+colorSuffix+">>") + } + } + + writeLine(f, ` +|=== +`) + } +} + +func (adoc adocReport) writeDataAssets() error { + filename := "19_DataAssets.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.dataAssets(f) + f.Close() + return nil +} + +func (adoc adocReport) trustBoundaries(f *os.File) { + writeLine(f, "= Trust Boundaries") + + word := "has" + if len(adoc.model.TrustBoundaries) > 1 { + word = "have" + } + writeLine(f, "In total *"+strconv.Itoa(len(adoc.model.TrustBoundaries))+" trust boundaries* "+word+" been "+ + "modeled during the threat modeling process.") + writeLine(f, "") + for _, trustBoundary := range sortedTrustBoundariesByTitle(adoc.model) { + colorPrefix := "[.Twilight]#" + colorSuffix := "#" + if !trustBoundary.Type.IsNetworkBoundary() { + colorPrefix = "[.LightGreyText]#" + } + writeLine(f, "[["+trustBoundary.Id+"]]") + writeLine(f, "== "+colorPrefix+trustBoundary.Title+colorSuffix) + writeLine(f, colorPrefix+trustBoundary.Description+colorSuffix) + writeLine(f, "") + + tagsUsedText := joinedOrNoneString(trustBoundary.Tags, "") + assetsInsideText := joinedOrNoneString(trustBoundary.TechnicalAssetsInside, "") + boundariesNestedText := joinedOrNoneString(trustBoundary.TrustBoundariesNested, "") + + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| ID: | `+trustBoundary.Id+` +| Type: | `+colorPrefix+trustBoundary.Type.String()+colorSuffix+` +| Tags: | `+tagsUsedText+` +| Assets inside: | `+assetsInsideText+` +| Boundaries nested: | `+boundariesNestedText+` +|=== +`) + } + +} + +func (adoc adocReport) writeTrustBoundaries() error { + filename := "20_TrustBoundaries.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.trustBoundaries(f) + f.Close() + return nil +} + +func (adoc adocReport) sharedRuntimes(f *os.File) { + writeLine(f, "= Shared Runtimes") + word, runtime := "has", "runtime" + if len(adoc.model.SharedRuntimes) > 1 { + word, runtime = "have", "runtimes" + } + writeLine(f, "In total *"+strconv.Itoa(len(adoc.model.SharedRuntimes))+" shared "+runtime+"* "+word+" been "+ + "modeled during the threat modeling process.") + writeLine(f, "") + for _, sharedRuntime := range sortedSharedRuntimesByTitle(adoc.model) { + writeLine(f, "[["+sharedRuntime.Id+"]]") + writeLine(f, "== "+sharedRuntime.Title) + writeLine(f, sharedRuntime.Description) + writeLine(f, "") + + tagsUsedText := joinedOrNoneString(sharedRuntime.Tags, "") + assetsRunningText := joinedOrNoneString(sharedRuntime.TechnicalAssetsRunning, "") + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| ID: | `+sharedRuntime.Id+` +| Tags: | `+tagsUsedText+` +| Assets running: | `+assetsRunningText+` +|=== +`) + } +} + +func (adoc adocReport) writeSharedRuntimes() error { + filename := "21_SharedRuntimes.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.sharedRuntimes(f) + f.Close() + return nil +} + +func (adoc adocReport) riskRulesChecked(f *os.File, modelFilename string, skipRiskRules []string, buildTimestamp string, threagileVersion string, modelHash string, customRiskRules types.RiskRules) { + writeLine(f, "= Risk Rules Checked by Threagile") + writeLine(f, "") + timestamp := time.Now() + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| Threagile Version: | `+threagileVersion+` +| Threagile Build Timestamp: | `+buildTimestamp+` +| Threagile Execution Timestamp: | `+timestamp.Format("20060102150405")+` +| Model Filename: | `+modelFilename+` +| Model Hash (SHA256): | `+modelHash+` +|=== +`) + writeLine(f, "\n\n") + writeLine(f, "Threagile (see https://threagile.io[] for more details) is an open-source toolkit for agile threat modeling, created by Christian Schneider (https://christian-schneider.net[]): It allows to model an architecture with its assets in an agile fashion as a YAML file "+ + "directly inside the IDE. Upon execution of the Threagile toolkit all standard risk rules (as well as individual custom rules if present) "+ + "are checked against the architecture model. At the time the Threagile toolkit was executed on the model input file "+ + "the following risk rules were checked:") + writeLine(f, "") + + // TODO use the new run system to discover risk rules instead of hard-coding them here: + skipped := "" + + for id, customRule := range customRiskRules { + if contains(skipRiskRules, id) { + skipped = "SKIPPED - " + } else { + skipped = "" + } + writeLine(f, "== "+skipped+customRule.Category().Title) + writeLine(f, "[.small]#"+id+"#") + writeLine(f, "") + writeLine(f, "_Custom Risk Rule_") + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| STRIDE: | `+customRule.Category().STRIDE.Title()+` +| Description: | `+firstParagraph(customRule.Category().Description)+` +| Detection: | `+customRule.Category().DetectionLogic+` +| Rating: | `+customRule.Category().RiskAssessment+` +|=== +`) + } + + sort.Sort(types.ByRiskCategoryTitleSort(adoc.model.CustomRiskCategories)) + for _, individualRiskCategory := range adoc.model.CustomRiskCategories { + writeLine(f, "== "+individualRiskCategory.Title) + writeLine(f, "[.small]#"+individualRiskCategory.ID+"#") + writeLine(f, "") + writeLine(f, "_Individual Risk category_") + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| STRIDE: | `+individualRiskCategory.STRIDE.Title()+` +| Description: | `+firstParagraph(individualRiskCategory.Description)+` +| Detection: | `+individualRiskCategory.DetectionLogic+` +| Rating: | `+individualRiskCategory.RiskAssessment+` +|=== +`) + } + + for _, rule := range adoc.riskRules { + if contains(skipRiskRules, rule.Category().ID) { + skipped = "SKIPPED - " + } else { + skipped = "" + } + writeLine(f, "== "+skipped+rule.Category().Title) + writeLine(f, "[.small]#"+rule.Category().ID+"#") + writeLine(f, "") + writeLine(f, ` +[cols="h,1",frame=none,grid=none] +|=== +| STRIDE: | `+rule.Category().STRIDE.Title()+` +| Description: | `+firstParagraph(rule.Category().Description)+` +| Detection: | `+rule.Category().DetectionLogic+` +| Rating: | `+rule.Category().RiskAssessment+` +|=== +`) + } +} + +func (adoc adocReport) writeRiskRulesChecked(modelFilename string, skipRiskRules []string, buildTimestamp string, threagileVersion string, modelHash string, customRiskRules types.RiskRules) error { + filename := "22_RiskRulesChecked.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.riskRulesChecked(f, modelFilename, skipRiskRules, buildTimestamp, threagileVersion, modelHash, customRiskRules) + f.Close() + return nil +} + +func (adoc adocReport) disclaimer(f *os.File) { + writeLine(f, "= Disclaimer") + + disclaimerColor := "[.silver]\n" + + writeLine(f, disclaimerColor+ + adoc.model.Author.Name+" conducted this threat analysis using the open-source Threagile toolkit "+ + "on the applications and systems that were modeled as of this report's date. "+ + "Information security threats are continually changing, with new "+ + "vulnerabilities discovered on a daily basis, and no application can ever be 100% secure no matter how much "+ + "threat modeling is conducted. It is recommended to execute threat modeling and also penetration testing on a regular basis "+ + "(for example yearly) to ensure a high ongoing level of security and constantly check for new attack vectors. "+ + "\n\n"+ + disclaimerColor+ + "This report cannot and does not protect against personal or business loss as the result of use of the "+ + "applications or systems described. "+adoc.model.Author.Name+" and the Threagile toolkit offers no warranties, representations or "+ + "legal certifications concerning the applications or systems it tests. All software includes defects: nothing "+ + "in this document is intended to represent or warrant that threat modeling was complete and without error, "+ + "nor does this document represent or warrant that the architecture analyzed is suitable to task, free of other "+ + "defects than reported, fully compliant with any industry standards, or fully compatible with any operating "+ + "system, hardware, or other application. Threat modeling tries to analyze the modeled architecture without "+ + "having access to a real working system and thus cannot and does not test the implementation for defects and vulnerabilities. "+ + "These kinds of checks would only be possible with a separate code review and penetration test against "+ + "a working system and not via a threat model."+ + "\n\n"+ + disclaimerColor+ + "By using the resulting information you agree that "+adoc.model.Author.Name+" and the Threagile toolkit "+ + "shall be held harmless in any event."+ + "\n\n"+ + disclaimerColor+ + "This report is confidential and intended for internal, confidential use by the client. The recipient "+ + "is obligated to ensure the highly confidential contents are kept secret. The recipient assumes responsibility "+ + "for further distribution of this document."+ + "\n\n"+ + disclaimerColor+ + "In this particular project, a time box approach was used to define the analysis effort. This means that the "+ + "author allotted a prearranged amount of time to identify and document threats. Because of this, there "+ + "is no guarantee that all possible threats and risks are discovered. Furthermore, the analysis "+ + "applies to a snapshot of the current state of the modeled architecture (based on the architecture information provided "+ + "by the customer) at the examination time."+ + "\n\n"+ + "== Report Distribution"+ + disclaimerColor+ + "Distribution of this report (in full or in part like diagrams or risk findings) requires that this disclaimer "+ + "as well as the chapter about the Threagile toolkit and method used is kept intact as part of the "+ + "distributed report or referenced from the distributed parts.") +} + +func (adoc adocReport) writeDisclaimer() error { + filename := "23_Disclaimer.adoc" + f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + if err != nil { + return err + } + adoc.writeMainLine("<<<") + adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") + + adoc.disclaimer(f) + f.Close() + return nil +} diff --git a/pkg/report/generate.go b/pkg/report/generate.go index 23a64492..3346b699 100644 --- a/pkg/report/generate.go +++ b/pkg/report/generate.go @@ -22,6 +22,7 @@ type GenerateCommands struct { RisksExcel bool TagsExcel bool ReportPDF bool + ReportADOC bool } func (c *GenerateCommands) Defaults() *GenerateCommands { @@ -34,6 +35,7 @@ func (c *GenerateCommands) Defaults() *GenerateCommands { RisksExcel: true, TagsExcel: true, ReportPDF: true, + ReportADOC: true, } return c } @@ -58,6 +60,7 @@ type reportConfigReader interface { GetJsonTechnicalAssetsFilename() string GetJsonStatsFilename() string GetTemplateFilename() string + GetReportLogoImagePath() string GetSkipRiskRules() []string GetRiskExcelConfigHideColumns() []string @@ -76,7 +79,7 @@ func Generate(config reportConfigReader, readResult *model.ReadResult, commands generateDataFlowDiagram := commands.DataFlowDiagram generateDataAssetsDiagram := commands.DataAssetDiagram - if commands.ReportPDF { // as the PDF report includes both diagrams + if commands.ReportPDF || commands.ReportADOC { // as the PDF report includes both diagrams if !generateDataFlowDiagram { dataFlowFile := filepath.Join(config.GetOutputFolder(), config.GetDataFlowDiagramFilenamePNG()) if _, err := os.Stat(dataFlowFile); errors.Is(err, os.ErrNotExist) { @@ -222,6 +225,38 @@ func Generate(config reportConfigReader, readResult *model.ReadResult, commands } } + if commands.ReportADOC { + // hash the YAML input file + f, err := os.Open(config.GetInputFile()) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return err + } + + modelHash := hex.EncodeToString(hasher.Sum(nil)) + // report ADOC + progressReporter.Info("Writing report adoc") + adocReporter := NewAdocReport(config.GetOutputFolder(), riskRules) + err = adocReporter.WriteReport(readResult.ParsedModel, + filepath.Join(config.GetOutputFolder(), config.GetDataFlowDiagramFilenamePNG()), + filepath.Join(config.GetOutputFolder(), config.GetDataAssetDiagramFilenamePNG()), + config.GetInputFile(), + config.GetSkipRiskRules(), + config.GetBuildTimestamp(), + config.GetThreagileVersion(), + modelHash, + readResult.IntroTextRAA, + readResult.CustomRiskRules, + config.GetReportLogoImagePath()) + if err != nil { + return err + } + } + return nil } diff --git a/report/threagile-logo.png b/report/threagile-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..12a4eb0efec7d54c40a3cdac983866c902479c25 GIT binary patch literal 65555 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 s8JJmuh5l105B=l+|A7AoZE*QX+-L#i9 Date: Tue, 2 Jul 2024 00:43:55 +0200 Subject: [PATCH 04/16] minor speed improvement --- pkg/report/report-helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/report/report-helper.go b/pkg/report/report-helper.go index 525793f7..8eaa01ba 100644 --- a/pkg/report/report-helper.go +++ b/pkg/report/report-helper.go @@ -21,8 +21,8 @@ func filteredByRiskStatus(parsedModel *types.Model, status types.RiskStatus) []* func filteredByRiskFunction(parsedModel *types.Model, function types.RiskFunction) []*types.Risk { filteredRisks := make([]*types.Risk, 0) for categoryId, risks := range parsedModel.GeneratedRisksByCategory { + category := parsedModel.GetRiskCategory(categoryId) for _, risk := range risks { - category := parsedModel.GetRiskCategory(categoryId) if category.Function == function { filteredRisks = append(filteredRisks, risk) } From 133c483bb0dbd097d78f89e25f47c7d3b9ca60ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Tue, 2 Jul 2024 11:20:43 +0200 Subject: [PATCH 05/16] add adoc documentation --- doc/asciidoctor-report.md | 83 +++++++++++++++++++++++++++++++++++++++ doc/custom-theme.yml | 51 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 doc/asciidoctor-report.md create mode 100644 doc/custom-theme.yml diff --git a/doc/asciidoctor-report.md b/doc/asciidoctor-report.md new file mode 100644 index 00000000..97182c7b --- /dev/null +++ b/doc/asciidoctor-report.md @@ -0,0 +1,83 @@ +# Asciidoctor Report Generator +The Asciidoctor Report Generator generates the report into asciidoctor files together with a default theme. This enables us to have a full flexibility of the generated output. One has the ability to create its own mainfile and include only the chapters that are needed for a special report. Also the themability of the Report is given. by simply using a different theme than the standard, but keep in mind that the roles from the default theme have to exist otherwise coloring of some texts might not match. + +## Report Generation + +To additionaly generate the adoc report you have to run threagile with the option `--generate-report-adoc` instead of `--generate-report-pdf` then a folder adocReport will be generated inside the output folder. After that you can run your favorite asciidoctor command to build a pdf or html page or whatever you want from it. The below example uses the asciidoctor docker container to create a pdf. + +1. Step, create adoc report +``` +$ mkdir -p /tmp/threagile-test +$ ./bin/threagile analyze-model \ + --model ./demo/example/threagile.yaml \ + --output /tmp/threagile-test \ + --ignore-orphaned-risk-tracking \ + --app-dir . \ + --generate-report-pdf=0 \ + --generate-report-adoc +``` +2. Step, create pdf from adoc +``` +$ docker run -it -u $(id -u):$(id -g) -v /tmp/threagile-test/adocReport:/documents/ asciidoctor/docker-asciidoctor \ + asciidoctor --verbose --require asciidoctor-pdf --backend pdf \ + --attribute allow-uri-read --require asciidoctor-kroki \ + --attribute DOC_VERSION=V1.0 \ + --attribute pdf-themesdir=/documents/theme --attribute pdf-theme=pdf \ + /documents/00_main.adoc +``` + +The generated report can then be found at `/tmp/threagile-test/adocReport/00_main.pdf` + +## Report Generation with custom main + +1. Generate the adoc report +2. Create a custom adoc file and use the only the generated parts (the example uses echo, bug copying from somewhere else might be better) +3. create pdf from it + +For example: +``` +$ mkdir -p /tmp/threagile-test +$ ./bin/threagile analyze-model \ + --model ./demo/example/threagile.yaml \ + --output /tmp/threagile-test \ + --ignore-orphaned-risk-tracking \ + --app-dir . \ + --generate-report-pdf=0 \ + --generate-report-adoc +$ echo "= Custom short threat model\n:title-page:\n:toc:\ninclude::03_RiskMitigationStatus.adoc[leveloffset=+1]\n<<<\ninclude::04_ImpactRemainingRisks.adoc[leveloffset=+1]" > /tmp/threagile-test/adocReport/my-main.adoc +$ docker run -it -u $(id -u):$(id -g) -v /tmp/threagile-test/adocReport:/documents/ asciidoctor/docker-asciidoctor \ + asciidoctor --verbose --require asciidoctor-pdf --backend pdf \ + --attribute allow-uri-read --require asciidoctor-kroki \ + --attribute DOC_VERSION=V1.0 \ + --attribute pdf-themesdir=/documents/theme --attribute pdf-theme=pdf \ + /documents/my-main.adoc +``` + +The generated report can then be found at `/tmp/threagile-test/adocReport/my-main.pdf` + +## Report Generation with custom theme + +1. Generate the adoc report +2. Create a custom theme, and place it there. For simplicity a very simple one could already be found next to this documetnation (`custom-theme.yml`), keep in mind that the role's that are in it are essential. To take a good base you should take the created one found in `/adocReport/theme/pdf.yml` and adjust it to your needs. +3. Create pdf from it + +For example: +``` +$ mkdir -p /tmp/threagile-test +$ ./bin/threagile analyze-model \ + --model ./demo/example/threagile.yaml \ + --output /tmp/threagile-test \ + --ignore-orphaned-risk-tracking \ + --app-dir . \ + --generate-report-pdf=0 \ + --generate-report-adoc +$ cp doc/custom-theme.yml /tmp/threagile-test/adocReport/theme/my-pdf-theme.yml +$ docker run -it -u $(id -u):$(id -g) -v /tmp/threagile-test/adocReport:/documents/ asciidoctor/docker-asciidoctor \ + asciidoctor --verbose --require asciidoctor-pdf --backend pdf \ + --attribute allow-uri-read --require asciidoctor-kroki \ + --attribute DOC_VERSION=V1.0 \ + --attribute pdf-themesdir=/documents/theme --attribute pdf-theme=my-pdf \ + /documents/00_main.adoc +``` + +The generated report can then be found at `/tmp/threagile-test/adocReport/00_main.pdf` diff --git a/doc/custom-theme.yml b/doc/custom-theme.yml new file mode 100644 index 00000000..dbac0dfc --- /dev/null +++ b/doc/custom-theme.yml @@ -0,0 +1,51 @@ +extends: default +footer: + height: 2cm + line-height: 1.2 + recto: + center: + content: "My Custom Theme" + right: + content: "Page {page-number} / {page-count}" + verso: + center: + content: "{document-title}" + right: + content: | + Page {page-number} / {page-count} +role: + LowRisk: + font-color: #23465F + MediumRisk: + font-color: #C87832 + ElevatedRisk: + font-color: #FF8E00 + HighRisk: + font-color: #A0281E + CriticalRisk: + font-color: #FF2600 + OutOfScope: + font-color: #7f7f7f + GreyText: + font-color: #505050 + LightGreyText: + font-color: #646464 + ModelFailure: + font-color: #945200 + RiskStatusFalsePositive: + font-color: #666666 + RiskStatusMitigated: + font-color: #008F00 + RiskStatusInProgress: + font-color: #0000FF + RiskStatusAccepted: + font-color: #FF40FF + RiskStatusInDiscussion: + font-color: #FF9300 + RiskStatusUnchecked: + font-color: #FF0000 + Twilight: + font-color: #3A52C8 + SmallGrey: + font-size: 0.5em + font-color: #505050 From dfcf6bf8a8378de4d62b6f350782ad53bae587e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Wed, 3 Jul 2024 22:54:20 +0200 Subject: [PATCH 06/16] add linting calls to Taskfile --- Taskfile.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index 0cc8031b..1aca6b7b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,7 +5,7 @@ vars: CONTAINER: asciidoctor/docker-asciidoctor # CONTAINER: docker-asciidoctor:local DOCKER_ASCIIDOCTOR: docker run -it -u $(id -u):$(id -g) -v $(pwd):/documents/ {{.CONTAINER}} asciidoctor {{.AD_GLOBAL_REQUIRES}} --require asciidoctor-kroki - GIT_SHORT_SHA: + GIT_SHORT_SHA: sh: git log -n 1 --format=%h env: @@ -61,3 +61,17 @@ tasks: --generate-report-adoc --generate-report-pdf=0 # --background ./report/template/background.pdf + + golangci-lint: + desc: run golangci-lint on current code + cmds: + - docker run --rm -it -v $(pwd):/app -w /app golangci/golangci-lint golangci-lint run -v + + gosec: + desc: run securego/gosec + cmds: + - docker run --rm -it -v $(pwd):/app -w /app securego/gosec /app/... + + linting: + desc: all linting jobs + deps: [golangci-lint, gosec] From 541e91f77852ca345c435241e7ec6a76a40aa495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Wed, 3 Jul 2024 22:54:56 +0200 Subject: [PATCH 07/16] fix issues found by linters --- pkg/report/adocReport.go | 91 ++++++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index 2ee0c307..283d0b2f 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -28,11 +28,13 @@ type adocReport struct { } func copyFile(source string, destination string) error { + /* #nosec source is not tainted (see caller restricting it to files we created ourself or are legitimate to be copied) */ src, err := os.Open(source) if err != nil { return err } defer func() { _ = src.Close() }() + /* #nosec destination is not tainted (see caller restricting it to the desired report output folder) */ dst, err := os.Create(destination) if err != nil { return err @@ -42,8 +44,6 @@ func copyFile(source string, destination string) error { if err != nil { return err } - dst.Close() - src.Close() return nil } @@ -74,19 +74,23 @@ func NewAdocReport(targetDirectory string, riskRules types.RiskRules) adocReport } func writeLine(file *os.File, line string) { - file.WriteString(line + "\n") + _, err := file.WriteString(line + "\n") + if err != nil { + log.Fatal("Could not write »" + line + "« into: " + file.Name() + ": " + err.Error()) + } } func (adoc adocReport) writeDefaultTheme(logoImagePath string) error { - err := os.MkdirAll(filepath.Join(adoc.targetDirectory, "theme"), 0755) + err := os.MkdirAll(filepath.Join(adoc.targetDirectory, "theme"), 0750) if err != nil { return err } - err = os.MkdirAll(adoc.imagesDir, 0755) + err = os.MkdirAll(adoc.imagesDir, 0750) if err != nil { return err } theme, err := os.Create(filepath.Join(adoc.targetDirectory, "theme", "pdf-theme.yml")) + defer func() { _ = theme.Close() }() if err != nil { return err } @@ -95,7 +99,11 @@ func (adoc adocReport) writeDefaultTheme(logoImagePath string) error { if _, err := os.Stat(logoImagePath); err == nil { suffix := filepath.Ext(logoImagePath) adocLogoPath = "logo" + suffix - copyFile(logoImagePath, filepath.Join(adoc.targetDirectory, "theme", adocLogoPath)) + logoDestPath := filepath.Join(adoc.targetDirectory, "theme", adocLogoPath) + err = copyFile(logoImagePath, logoDestPath) + if err != nil { + log.Fatal("Could not copy file: »" + logoImagePath + "« to »" + logoDestPath + "«: " + err.Error()) + } } else { log.Println("logo image path does not exist: " + logoImagePath) } @@ -183,7 +191,6 @@ role: font-color: #505050 `) - theme.Close() return nil } @@ -311,8 +318,8 @@ func (adoc adocReport) WriteReport(model *types.Model, } func (adoc *adocReport) initReport() error { - os.RemoveAll(adoc.targetDirectory) - err := os.MkdirAll(adoc.targetDirectory, 0755) + _ = os.RemoveAll(adoc.targetDirectory) + err := os.MkdirAll(adoc.targetDirectory, 0750) if err != nil { return err } @@ -348,6 +355,7 @@ func (adoc adocReport) writeTitleAndPreamble() { func (adoc adocReport) writeManagementSummery() error { filename := "01_ManagementSummary.adoc" ms, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = ms.Close() }() if err != nil { return err } @@ -416,7 +424,6 @@ pie showData writeLine(ms, "\n\n\n"+fixBasicHtml(adoc.model.ManagementSummaryComment)) } - ms.Close() return nil } @@ -472,7 +479,7 @@ func titleOfSeverity(severity types.RiskSeverity) string { } } -func (adoc adocReport) addCategories(f *os.File, risksByCategory map[string][]*types.Risk, initialRisks bool, severity types.RiskSeverity, bothInitialAndRemainingRisks bool, describeDescription bool) error { +func (adoc adocReport) addCategories(f *os.File, risksByCategory map[string][]*types.Risk, initialRisks bool, severity types.RiskSeverity, bothInitialAndRemainingRisks bool, describeDescription bool) { describeImpact := true riskCategories := getRiskCategories(adoc.model, reduceToSeverityRisk(risksByCategory, initialRisks, severity)) sort.Sort(types.ByRiskCategoryTitleSort(riskCategories)) @@ -538,7 +545,6 @@ func (adoc adocReport) addCategories(f *os.File, risksByCategory map[string][]*t writeLine(f, firstParagraph(riskCategory.Mitigation)) } } - return nil } func (adoc adocReport) impactAnalysis(f *os.File, initialRisks bool) { @@ -583,6 +589,7 @@ func (adoc adocReport) impactAnalysis(f *os.File, initialRisks bool) { func (adoc adocReport) writeImpactInitialRisks() error { filename := "02_ImpactIntialRisks.adoc" ir, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = ir.Close() }() if err != nil { return err } @@ -590,7 +597,6 @@ func (adoc adocReport) writeImpactInitialRisks() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.impactAnalysis(ir, true) - ir.Close() return nil } @@ -745,6 +751,7 @@ pie showData func (adoc adocReport) writeRiskMitigationStatus() error { filename := "03_RiskMitigationStatus.adoc" rms, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = rms.Close() }() if err != nil { return err } @@ -752,13 +759,13 @@ func (adoc adocReport) writeRiskMitigationStatus() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.riskMitigationStatus(rms) - rms.Close() return nil } func (adoc adocReport) writeImpactRemainingRisks() error { filename := "04_ImpactRemainingRisks.adoc" irr, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = irr.Close() }() if err != nil { return err } @@ -766,7 +773,6 @@ func (adoc adocReport) writeImpactRemainingRisks() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.impactAnalysis(irr, false) - irr.Close() return nil } @@ -816,6 +822,7 @@ func (adoc adocReport) targetDescription(f *os.File, baseFolder string) { func (adoc adocReport) writeTargetDescription(baseFolder string) error { filename := "05_TargetDescription.adoc" td, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = td.Close() }() if err != nil { return err } @@ -823,7 +830,6 @@ func (adoc adocReport) writeTargetDescription(baseFolder string) error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.targetDescription(td, baseFolder) - td.Close() return nil } @@ -839,8 +845,13 @@ For a full high-resolution version of this diagram please refer to the PNG image } func imageIsWiderThanHigh(diagramFilenamePNG string) bool { - imagePath, _ := os.Open(diagramFilenamePNG) + /* #nosec diagramFilenamePNG is not tainted (see caller restricting it to image files of model folder only) */ + imagePath, err := os.Open(diagramFilenamePNG) defer func() { _ = imagePath.Close() }() + if err != nil { + log.Fatalln("error opening image file: %w", err) + return false + } srcImage, _, _ := image.Decode(imagePath) srcDimensions := srcImage.Bounds() // wider than high? @@ -851,11 +862,15 @@ func imageIsWiderThanHigh(diagramFilenamePNG string) bool { func (adoc adocReport) writeDataFlowDiagram(diagramFilenamePNG string) error { filename := "06_DataFlowDiagram.adoc" dfd, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = dfd.Close() }() if err != nil { return err } adocDfdFilename := filepath.Join(adoc.imagesDir, "data-flow-diagram.png") - copyFile(diagramFilenamePNG, adocDfdFilename) + err = copyFile(diagramFilenamePNG, adocDfdFilename) + if err != nil { + log.Fatal("Could not copy file: »" + diagramFilenamePNG + "« to »" + adocDfdFilename + "«: " + err.Error()) + } landScape := imageIsWiderThanHigh(adocDfdFilename) if landScape { @@ -868,7 +883,6 @@ func (adoc adocReport) writeDataFlowDiagram(diagramFilenamePNG string) error { if landScape { adoc.writeMainLine("[page-layout=portrait]") } - dfd.Close() return nil } @@ -890,6 +904,7 @@ func (adoc adocReport) securityRequirements(f *os.File) { func (adoc adocReport) writeSecurityRequirements() error { filename := "07_SecurityRequirements.adoc" sr, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = sr.Close() }() if err != nil { return err } @@ -897,7 +912,6 @@ func (adoc adocReport) writeSecurityRequirements() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.securityRequirements(sr) - sr.Close() return nil } @@ -918,6 +932,7 @@ func (adoc adocReport) abuseCases(f *os.File) { func (adoc adocReport) writeAbuseCases() error { filename := "08_AbuseCases.adoc" ac, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = ac.Close() }() if err != nil { return err } @@ -925,7 +940,6 @@ func (adoc adocReport) writeAbuseCases() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.abuseCases(ac) - ac.Close() return nil } @@ -988,6 +1002,7 @@ func (adoc adocReport) tagListing(f *os.File) { func (adoc adocReport) writeTagListing() error { filename := "09_TagListing.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -995,7 +1010,6 @@ func (adoc adocReport) writeTagListing() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.tagListing(f) - f.Close() return nil } @@ -1057,6 +1071,7 @@ func (adoc adocReport) stride(f *os.File) { func (adoc adocReport) writeSTRIDE() error { filename := "10_STRIDE.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1064,7 +1079,6 @@ func (adoc adocReport) writeSTRIDE() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.stride(f) - f.Close() return nil } @@ -1118,6 +1132,7 @@ func (adoc adocReport) assignmentByFunction(f *os.File) { func (adoc adocReport) writeAssignmentByFunction() error { filename := "11_AssignmentByFunction.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1125,7 +1140,6 @@ func (adoc adocReport) writeAssignmentByFunction() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.assignmentByFunction(f) - f.Close() return nil } @@ -1173,6 +1187,7 @@ func (adoc adocReport) raa(f *os.File, introTextRAA string) { func (adoc adocReport) writeRAA(introTextRAA string) error { filename := "12_RAA.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1180,7 +1195,6 @@ func (adoc adocReport) writeRAA(introTextRAA string) error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.raa(f, introTextRAA) - f.Close() return nil } @@ -1199,11 +1213,15 @@ refer to the PNG image file alongside this report.`) func (adoc adocReport) writeDataRiskMapping(dataAssetDiagramFilenamePNG string) error { filename := "13_DataRiskMapping.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } adocDataRiskMappingFilename := filepath.Join(adoc.imagesDir, "data-asset-diagram.png") - copyFile(dataAssetDiagramFilenamePNG, adocDataRiskMappingFilename) + err = copyFile(dataAssetDiagramFilenamePNG, adocDataRiskMappingFilename) + if err != nil { + log.Fatal("Could not copy file: »" + dataAssetDiagramFilenamePNG + "« to »" + adocDataRiskMappingFilename + "«: " + err.Error()) + } landScape := imageIsWiderThanHigh(adocDataRiskMappingFilename) if landScape { @@ -1216,7 +1234,6 @@ func (adoc adocReport) writeDataRiskMapping(dataAssetDiagramFilenamePNG string) if landScape { adoc.writeMainLine("[page-layout=portrait]") } - f.Close() return nil } @@ -1252,6 +1269,7 @@ Each one should be checked in the model whether it should better be included in func (adoc adocReport) writeOutOfScopeAssets() error { filename := "14_OutOfScopeAssets.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1259,7 +1277,6 @@ func (adoc adocReport) writeOutOfScopeAssets() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.outOfScopeAssets(f) - f.Close() return nil } @@ -1302,6 +1319,7 @@ in the model against the architecture design:{fn-risk-findings}`) func (adoc adocReport) writeModelFailures() error { filename := "15_ModelFailures.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1309,7 +1327,6 @@ func (adoc adocReport) writeModelFailures() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.modelFailures(f) - f.Close() return nil } @@ -1351,6 +1368,7 @@ func (adoc adocReport) questions(f *os.File) { func (adoc adocReport) writeQuestions() error { filename := "16_Questions.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1358,7 +1376,6 @@ func (adoc adocReport) writeQuestions() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.questions(f) - f.Close() return nil } @@ -1543,6 +1560,7 @@ func (adoc adocReport) riskCategories(f *os.File) { func (adoc adocReport) writeRiskCategories() error { filename := "17_RiskCategories.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1550,7 +1568,6 @@ func (adoc adocReport) writeRiskCategories() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.riskCategories(f) - f.Close() return nil } @@ -1781,6 +1798,7 @@ func (adoc adocReport) technicalAssets(f *os.File) { func (adoc adocReport) writeTechnicalAssets() error { filename := "18_TechnicalAssets.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1788,7 +1806,6 @@ func (adoc adocReport) writeTechnicalAssets() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.technicalAssets(f) - f.Close() return nil } @@ -1886,6 +1903,7 @@ func (adoc adocReport) dataAssets(f *os.File) { func (adoc adocReport) writeDataAssets() error { filename := "19_DataAssets.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1893,7 +1911,6 @@ func (adoc adocReport) writeDataAssets() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.dataAssets(f) - f.Close() return nil } @@ -1939,6 +1956,7 @@ func (adoc adocReport) trustBoundaries(f *os.File) { func (adoc adocReport) writeTrustBoundaries() error { filename := "20_TrustBoundaries.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1946,7 +1964,6 @@ func (adoc adocReport) writeTrustBoundaries() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.trustBoundaries(f) - f.Close() return nil } @@ -1981,6 +1998,7 @@ func (adoc adocReport) sharedRuntimes(f *os.File) { func (adoc adocReport) writeSharedRuntimes() error { filename := "21_SharedRuntimes.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -1988,7 +2006,6 @@ func (adoc adocReport) writeSharedRuntimes() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.sharedRuntimes(f) - f.Close() return nil } @@ -2078,6 +2095,7 @@ func (adoc adocReport) riskRulesChecked(f *os.File, modelFilename string, skipRi func (adoc adocReport) writeRiskRulesChecked(modelFilename string, skipRiskRules []string, buildTimestamp string, threagileVersion string, modelHash string, customRiskRules types.RiskRules) error { filename := "22_RiskRulesChecked.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -2085,7 +2103,6 @@ func (adoc adocReport) writeRiskRulesChecked(modelFilename string, skipRiskRules adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.riskRulesChecked(f, modelFilename, skipRiskRules, buildTimestamp, threagileVersion, modelHash, customRiskRules) - f.Close() return nil } @@ -2140,6 +2157,7 @@ func (adoc adocReport) disclaimer(f *os.File) { func (adoc adocReport) writeDisclaimer() error { filename := "23_Disclaimer.adoc" f, err := os.Create(filepath.Join(adoc.targetDirectory, filename)) + defer func() { _ = f.Close() }() if err != nil { return err } @@ -2147,6 +2165,5 @@ func (adoc adocReport) writeDisclaimer() error { adoc.writeMainLine("include::" + filename + "[leveloffset=+1]") adoc.disclaimer(f) - f.Close() return nil } From ff82484baaf22db680f26a3590541a0fe87e1836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Thu, 4 Jul 2024 12:18:17 +0200 Subject: [PATCH 08/16] some smaller theme improvements --- pkg/report/adocReport.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index 283d0b2f..bdadd9c2 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -128,27 +128,23 @@ title-page: line-height: 1 recto: center: - content: "{section-or-chapter-title} - `+adoc.model.Title+`" + content: "{document-title} -- `+adoc.model.Title+` -- {section-or-chapter-title}" verso: center: - content: "{section-or-chapter-title} - `+adoc.model.Title+`" + content: "{document-title} -- `+adoc.model.Title+` -- {section-or-chapter-title}" footer: height: 2cm line-height: 1.2 recto: center: - content: | - --confidential -- - {document-title} + content: -- confidential -- left: content: "Version: {DOC_VERSION}" right: content: "Page {page-number} of {page-count}" verso: center: - content: | - --confidential -- - {document-title} + content: -- confidential -- left: content: "Version: {DOC_VERSION}" right: From 7696e55cf14c15b72d2aba4369ba7948f9fad35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Thu, 4 Jul 2024 15:44:57 +0200 Subject: [PATCH 09/16] fix order in bar graph --- pkg/report/adocReport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index bdadd9c2..d3628a88 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -665,8 +665,8 @@ func (adoc adocReport) riskMitigationStatus(f *os.File) { }, "mark": {"type": "bar", "cornerRadiusTopLeft": 3, "cornerRadiusTopRight": 3}, "encoding": { - "x": {"field": "risk", "type": "ordinal", "title": "", "axis": { - "labelAngle": 0 + "x": {"field": "risk", "type": "ordinal", "title": "", "sort": [], "axis": { + "labelAngle": 0 }}, "y": {"field": "value", "type": "quantitative", "title": "", "axis": { "orient": "right" From a1f5093338d59791cc89df7e1624d0de48f7e886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Fri, 5 Jul 2024 12:12:52 +0200 Subject: [PATCH 10/16] fixed some issues for empty fields --- pkg/report/adocReport.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index d3628a88..e7c38568 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -1406,13 +1406,17 @@ func (adoc adocReport) riskTrackingStatus(f *os.File, risk *types.Risk) { dateStr = "" } justificationStr := tracking.Justification + ticket := tracking.Ticket + if len(ticket) == 0 { + ticket = "-" + } writeLine(f, ` [cols="a,c,c,c",frame=none,grid=none,options="unbreakable"] |=== | [.`+colorName+`.small]#`+bold+tracking.Status.Title()+bold+`# | [.GreyText.small]#`+dateStr+`# | [.GreyText.small]#`+tracking.CheckedBy+`# -| [.GreyText.small]#`+tracking.Ticket+`# +| [.GreyText.small]#`+ticket+`# 4+|[.small]#`+justificationStr+`# |=== @@ -1477,10 +1481,12 @@ func (adoc adocReport) riskCategories(f *os.File) { writeLine(f, fixBasicHtml(category.RiskAssessment)) writeLine(f, "[RiskStatusFalsePositive]#*False Positives*#::") - writeLine(f, "[RiskStatusFalsePositive]#"+category.FalsePositives+"#") + if len(category.FalsePositives) > 0 { + writeLine(f, "[RiskStatusFalsePositive]#"+category.FalsePositives+"#") + } writeLine(f, "[RiskStatusMitigated]#*Mitigation*# ("+category.Function.Title()+"): "+category.Action+"::") - writeLine(f, "[RiskStatusMitigated]#"+fixBasicHtml(category.Mitigation)+"#") + writeLine(f, fixBasicHtml(category.Mitigation)) asvsChapter := category.ASVS asvsLink := "n/a" From b1a9d29bf7779d06a2f73407a43da2eb9346dae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Sun, 14 Jul 2024 22:09:42 +0200 Subject: [PATCH 11/16] improve Taskfile to allow other threagile yamls just overload the YAML env var. for example `YAML=/tmp/foo.threagile.yaml task create-example-project` --- Taskfile.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index 1aca6b7b..62566775 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -51,10 +51,12 @@ tasks: create-example-project: desc: create the example project deps: [build-threagile] + env: + YAML: ./demo/example/threagile.yaml cmds: - mkdir -p /tmp/threagile-test - ./bin/threagile analyze-model - --model ./demo/example/threagile.yaml + --model ${YAML} --output /tmp/threagile-test --ignore-orphaned-risk-tracking --app-dir . From 98aee0e57480faaa3e84d079f409bafc4bd5035a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Sun, 14 Jul 2024 22:10:10 +0200 Subject: [PATCH 12/16] fix html links in buildin risks --- pkg/report/adocReport.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index e7c38568..3d1ea123 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -59,6 +60,9 @@ func fixBasicHtml(inputWithHtml string) string { result = strings.Replace(result, "
", "\n", -1) result = strings.Replace(result, "
", "\n", -1) + + linkAndName := regexp.MustCompile(`(.*)`) + result = linkAndName.ReplaceAllString(result, "${1}[${2}]") return result } From 093fe550bd72d7103b546e54671496a8cf779769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Sun, 14 Jul 2024 22:12:33 +0200 Subject: [PATCH 13/16] fix "small grey" footnote to be a real footnote --- pkg/report/adocReport.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index 3d1ea123..0965b68a 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -1665,7 +1665,7 @@ func (adoc adocReport) technicalAssets(f *os.File) { // and more metadata of asset in tabular view writeLine(f, "=== Identified Risks of Asset") if len(risksStr) > 0 { - writeLine(f, "[GreyText]#Risk finding paragraphs are clickable and link to the corresponding chapter.#") + writeLine(f, ":fn-risk-findings: footnote:riskfinding[Risk finding paragraphs are clickable and link to the corresponding chapter.]") for _, risk := range risksStr { colorPrefix, colorSuffix = colorPrefixBySeverity(types.HighestSeverityStillAtRisk(risksStr), false) if !risk.RiskStatus.IsStillAtRisk() { From c672579b1d0bf3eb4429c1a440fdc9da3804a38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Fri, 26 Jul 2024 14:38:49 +0200 Subject: [PATCH 14/16] fix docker build --- Dockerfile.local | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.local b/Dockerfile.local index 1e157a9a..0e444cfc 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -40,8 +40,8 @@ FROM alpine:latest as finalize LABEL type="threagile" # update vulnerable packages -RUN apk add libcrypto3=3.3.1-r0 -RUN apk add libssl3=3.3.1-r0 +RUN apk add libcrypto3=3.3.1-r3 +RUN apk add libssl3=3.3.1-r3 # add certificates, graphviz, fonts RUN apk add --update --no-cache ca-certificates From 197fd70363c5cd425ffaaa43be992dbe92a7bca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Tue, 30 Jul 2024 17:18:08 +0200 Subject: [PATCH 15/16] fix page break before asset information --- pkg/report/adocReport.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index 0965b68a..58f6ce4a 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -1688,7 +1688,9 @@ func (adoc adocReport) technicalAssets(f *os.File) { } // ASSET INFORMATION + writeLine(f, "") writeLine(f, "<<<") + writeLine(f, "") writeLine(f, "=== Asset Information") textRAA := fmt.Sprintf("%.0f", technicalAsset.RAA) + " %" if technicalAsset.OutOfScope { From 0c91ac9e2dd39efe36b1c20526c34566a760f9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=A4fer?= Date: Mon, 5 Aug 2024 14:00:30 +0200 Subject: [PATCH 16/16] fix a color code issue of the disclaimer --- pkg/report/adocReport.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/report/adocReport.go b/pkg/report/adocReport.go index 58f6ce4a..c16ad48f 100644 --- a/pkg/report/adocReport.go +++ b/pkg/report/adocReport.go @@ -189,6 +189,8 @@ role: SmallGrey: font-size: 0.5em font-color: #505050 + Silver: + font-color: #C0C0C0 `) return nil @@ -2117,7 +2119,7 @@ func (adoc adocReport) writeRiskRulesChecked(modelFilename string, skipRiskRules func (adoc adocReport) disclaimer(f *os.File) { writeLine(f, "= Disclaimer") - disclaimerColor := "[.silver]\n" + disclaimerColor := "\n[.Silver]\n" writeLine(f, disclaimerColor+ adoc.model.Author.Name+" conducted this threat analysis using the open-source Threagile toolkit "+