Initial commit

This commit is contained in:
Justin McCarthy 2018-05-15 14:13:11 -07:00
commit 1746fe3ee7
No known key found for this signature in database
GPG Key ID: 900437410E142A48
1044 changed files with 265229 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
comply
output
dist

3
AUTHORS.txt Normal file
View File

@ -0,0 +1,3 @@
# Authors in alphabetical order:
Justin McCarthy <justin@strongdm.com>

262
Gopkg.lock generated Normal file
View File

@ -0,0 +1,262 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/Microsoft/go-winio"
packages = ["."]
revision = "7da180ee92d8bd8bb8c37fc560e673e6557c392f"
version = "v0.4.7"
[[projects]]
branch = "master"
name = "github.com/chzyer/readline"
packages = ["."]
revision = "f6d7a1f6fbf35bbf9beb80dc63c56a29dcfb759f"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/docker/distribution"
packages = [
"digest",
"reference"
]
revision = "48294d928ced5dd9b378f7fd7c6f5da3ff3f2c89"
version = "v2.6.2"
[[projects]]
name = "github.com/docker/docker"
packages = [
"api/types",
"api/types/blkiodev",
"api/types/container",
"api/types/events",
"api/types/filters",
"api/types/mount",
"api/types/network",
"api/types/reference",
"api/types/registry",
"api/types/strslice",
"api/types/swarm",
"api/types/time",
"api/types/versions",
"api/types/volume",
"client",
"pkg/tlsconfig"
]
revision = "092cba3727bb9b4a2f0e922cd6c0f93ea270e363"
version = "v1.13.1"
[[projects]]
name = "github.com/docker/go-connections"
packages = [
"nat",
"sockets",
"tlsconfig"
]
revision = "3ede32e2033de7505e6500d6c868c2b9ed9f169d"
version = "v0.3.0"
[[projects]]
name = "github.com/docker/go-units"
packages = ["."]
revision = "0dadbb0345b35ec7ef35e228dabb8de89a65bf52"
version = "v0.3.2"
[[projects]]
name = "github.com/elazarl/go-bindata-assetfs"
packages = ["."]
revision = "30f82fa23fd844bd5bb1e5f216db87fd77b5eb43"
version = "v1.0.0"
[[projects]]
name = "github.com/fatih/color"
packages = ["."]
revision = "507f6050b8568533fb3f5504de8e5205fa62a114"
version = "v1.6.0"
[[projects]]
name = "github.com/fsnotify/fsnotify"
packages = ["."]
revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9"
version = "v1.4.7"
[[projects]]
name = "github.com/gohugoio/hugo"
packages = ["watcher"]
revision = "f414966b942b5aad75565bee6c644782a07f0658"
version = "v0.37.1"
[[projects]]
name = "github.com/golang/protobuf"
packages = ["proto"]
revision = "925541529c1fa6821df4e44ce2723319eb2be768"
version = "v1.0.0"
[[projects]]
name = "github.com/google/go-github"
packages = ["github"]
revision = "e48060a28fac52d0f1cb758bc8b87c07bac4a87d"
version = "v15.0.0"
[[projects]]
branch = "master"
name = "github.com/google/go-querystring"
packages = ["query"]
revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a"
[[projects]]
name = "github.com/gorilla/websocket"
packages = ["."]
revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b"
version = "v1.2.0"
[[projects]]
branch = "master"
name = "github.com/jcelliott/lumber"
packages = ["."]
revision = "dd349441af25132d146d7095c6693a15431fc9b1"
[[projects]]
branch = "master"
name = "github.com/juju/ansiterm"
packages = [
".",
"tabwriter"
]
revision = "720a0952cc2ac777afc295d9861263e2a4cf96a1"
[[projects]]
branch = "master"
name = "github.com/lunixbochs/vtclean"
packages = ["."]
revision = "d14193dfc626125c831501c1c42340b4248e1f5a"
[[projects]]
branch = "master"
name = "github.com/manifoldco/promptui"
packages = [
".",
"list",
"screenbuf"
]
revision = "c0c0d3afc6a03bcb5c1df10b70b862a650db9f9b"
[[projects]]
name = "github.com/mattn/go-colorable"
packages = ["."]
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
version = "v0.0.9"
[[projects]]
name = "github.com/mattn/go-isatty"
packages = ["."]
revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
version = "v0.0.3"
[[projects]]
name = "github.com/mattn/go-runewidth"
packages = ["."]
revision = "9e777a8366cce605130a531d2cd6363d07ad7317"
version = "v0.0.2"
[[projects]]
branch = "master"
name = "github.com/nanobox-io/golang-scribble"
packages = ["."]
revision = "ced58d671850da57ce8c11315424513b608083d7"
[[projects]]
branch = "master"
name = "github.com/olekukonko/tablewriter"
packages = ["."]
revision = "b8a9be070da40449e501c3c4730a889e42d87a9e"
[[projects]]
name = "github.com/pkg/errors"
packages = ["."]
revision = "645ef00459ed84a119197bfb8d8205042c6df63d"
version = "v0.8.0"
[[projects]]
name = "github.com/robfig/cron"
packages = ["."]
revision = "b024fc5ea0e34bc3f83d9941c8d60b0622bfaca4"
version = "v1"
[[projects]]
branch = "master"
name = "github.com/skratchdot/open-golang"
packages = ["open"]
revision = "75fb7ed4208cf72d323d7d02fd1a5964a7a9073c"
[[projects]]
name = "github.com/urfave/cli"
packages = ["."]
revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
version = "v1.20.0"
[[projects]]
name = "github.com/yosssi/ace"
packages = ["."]
revision = "ea038f4770b6746c3f8f84f14fa60d9fe1205b56"
version = "v0.0.5"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"proxy"
]
revision = "d0aafc73d5cdc42264b0af071c261abac580695e"
[[projects]]
branch = "master"
name = "golang.org/x/oauth2"
packages = [
".",
"internal"
]
revision = "7af32f14d0a25aec7873e0683e8e48dcead159a8"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = [
"unix",
"windows"
]
revision = "dd2ff4accc098aceecb86b36eaa7829b2a17b1c9"
[[projects]]
name = "google.golang.org/appengine"
packages = [
"internal",
"internal/base",
"internal/datastore",
"internal/log",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
[[projects]]
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5"
version = "v2.1.1"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "4fd2ff9f9869c3f3e30601504f4b00fce69d282ae8df42583a1c60848bfd0766"
solver-name = "gps-cdcl"
solver-version = 1

30
Gopkg.toml Normal file
View File

@ -0,0 +1,30 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true
[prune]
go-tests = true
unused-packages = true

202
LICENSE.txt Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

127
Makefile Normal file
View File

@ -0,0 +1,127 @@
.DEFAULT_GOAL := comply
GO_SOURCES := $(shell find . -name '*.go')
THEME_SOURCES := $(shell find themes)
assets: $(THEME_SOURCES)
@go get github.com/jteeuwen/go-bindata/...
@go get github.com/elazarl/go-bindata-assetfs/...
@go install github.com/elazarl/go-bindata-assetfs
go-bindata-assetfs -o bindata.go -pkg theme -prefix themes themes/...
mv bindata.go internal/theme/themes_bindata.go
comply: assets $(GO_SOURCES)
@# $(eval VERSION := $(shell git describe --tags --always --dirty="-dev"))
@# $(eval LDFLAGS := -ldflags='-X "github.com/strongdm/comply/internal/cli.Version=$(VERSION)"')
go build $(LDFLAGS) github.com/strongdm/comply
dist: clean
$(eval VERSION := $(shell git describe --tags --always --dirty="-dev"))
$(eval LDFLAGS := -ldflags='-X "github.com/strongdm/comply/internal/cli.Version=$(VERSION)"')
mkdir dist
echo $(VERSION)
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -gcflags=-trimpath=$(GOPATH) -asmflags=-trimpath=$(GOPATH) $(LDFLAGS) -o dist/comply-$(VERSION)-darwin-amd64 .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -gcflags=-trimpath=$(GOPATH) -asmflags=-trimpath=$(GOPATH) $(LDFLAGS) -o dist/comply-$(VERSION)-linux-amd64 .
cd dist && tar -czvf comply-$(VERSION)-darwin-amd64.tgz comply-$(VERSION)-darwin-amd64
cd dist && tar -czvf comply-$(VERSION)-linux-amd64.tgz comply-$(VERSION)-linux-amd64
brew: clean assets $(GO_SOURCES)
$(eval VERSION := $(shell cat version))
$(eval LDFLAGS := -ldflags='-X "github.com/strongdm/comply/internal/cli.Version=$(VERSION)"')
mkdir bin
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -gcflags=-trimpath=$(GOPATH) -asmflags=-trimpath=$(GOPATH) $(LDFLAGS) -o bin/comply .
clean:
rm -rf bin
rm -rf dist
rm -f comply
install: assets $(GO_SOURCES)
go install github.com/strongdm/comply
push-assets: is-clean export-example assets
git commit -am "automated asset refresh (via Makefile)"
git push
is-clean:
ifeq ($(strip $(shell git status --porcelain 2>/dev/null)),)
# good to proceed
else
@echo working directory must be clean to proceed
@exit 1
endif
export-example:
cp example/narratives/* themes/comply-soc2/narratives
cp example/procedures/* themes/comply-soc2/procedures
cp example/policies/* themes/comply-soc2/policies
cp example/standards/* themes/comply-soc2/standards
cp example/templates/* themes/comply-soc2/templates
cp example/templates/* themes/comply-blank/templates
cp example/*.md themes/comply-soc2
cp example/*.md themes/comply-blank
docker:
cd build && docker build -t strongdm/pandoc .
docker tag jagregory/pandoc:latest strongdm/pandoc:latest
docker push strongdm/pandoc
cleanse:
git checkout --orphan newbranch
git add -A
git commit -m "Initial commit"
git branch -D master
git branch -m master
git push -f origin master
git gc --aggressive --prune=all
release-env:
ifndef GH_LOGIN
$(error GH_LOGIN must be set to a valid GitHub token)
endif
release: release-env dist release-deps
$(eval VERSION := $(shell git describe --tags --always --dirty="-dev"))
github-release release \
--security-token $$GH_LOGIN \
--user strongdm \
--repo comply \
--tag $(VERSION) \
--name $(VERSION)
github-release upload \
--security-token $$GH_LOGIN \
--user strongdm \
--repo comply \
--tag $(VERSION) \
--name comply-$(VERSION)-darwin-amd64.tgz \
--file dist/comply-$(VERSION)-darwin-amd64.tgz
github-release upload \
--security-token $$GH_LOGIN \
--user strongdm \
--repo comply \
--tag $(VERSION) \
--name comply-$(VERSION)-linux-amd64.tgz \
--file dist/comply-$(VERSION)-linux-amd64.tgz
patch-release: release-env patch release
minor-release: release-env minor release
patch: clean gitsem
gitsem -m "increment patch for release" patch
git push
git push origin --tags
minor: clean gitsem
gitsem -m "increment minor for release" minor
git push
git push origin --tags
release-deps: gitsem gh-release
gitsem:
go get -u github.com/Clever/gitsem
gh-release:
go get -u github.com/aktau/github-release

67
README.md Normal file
View File

@ -0,0 +1,67 @@
# Comply
Comply is a SOC2-focused compliance automation tool:
- **Policy Generator**: markdown-powered **document pipeline** for publishing auditor-friendly **policy documents**
- **Ticketing Integration**: manage the lifecycle of your program throughout the year via your existing **ticketing system**
- **SOC2 Templates**: open source policy and procedure **templates** suitable for satisfying a SOC2 audit
# Installation
macOS:
`brew install comply`
Go users:
`go get github.com/strongdm/comply`
# Get Started
Start with `comply init`:
```
$ mkdir my-company
$ cd my-company
$ comply init
```
# Discussion
Join us in [Comply Users](https://join.slack.com/t/comply-users/shared_invite/enQtMzU3MTk5MDkxNDU4LTMwYzZkMjA4YjQ2YTM5Zjc0NTAyYWY5MDBlOGMwMzRmZTk5YzBlOTRiMTVlNGRlZjY1MTY1NDE0MjY5ZjYwNWU)
# Screenshots
## Start a Project
![screencast 1](sc-1.gif)
## Build PDFs
![screencast 4](sc-4.gif)
![pdf example](pdf-example.png)
## Track Policy Coverage
![screencast 3](sc-2.gif)
## Dashboard
![screencast 2](sc-3.gif)
## CLI
```
NAME:
comply - policy compliance toolkit
USAGE:
comply [global options] command [command options] [arguments...]
COMMANDS:
init initialize a new compliance repository (interactive)
build, b generate a static website summarizing the compliance program
scheduler create tickets based on procedure schedule
serve live updating version of the build command
sync sync ticket status to local cache
todo list declared vs satisfied compliance controls
help, h Shows a list of commands or help for one command
```

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.1.15

3
build/Dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM scratch
MAINTAINER strongDM Comply <comply@strongdm.com>

7
comply.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/strongdm/comply/internal/cli"
func main() {
cli.Main()
}

22
comply.rb Normal file
View File

@ -0,0 +1,22 @@
class Comply < Formula
desc ""
homepage ""
url "file:///Users/jmccarthy/Downloads/comply-1.1.3.tgz"
sha256 "01f9920e5e9fbd29386e4a4131fac78c002730e49c3f870a0ee2b958c3ce75f6"
depends_on "go" => :build
def install
ENV["GOPATH"] = buildpath
ENV.prepend_create_path "PATH", buildpath/"bin"
(buildpath/"src/github.com/strongdm/comply").install buildpath.children
cd "src/github.com/strongdm/comply" do
system "make", "brew"
bin.install "bin/comply"
end
end
test do
system "#{bin}/sdm", "logout"
end
end

3
example/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
output
.comply
comply.yml

60
example/README.md Normal file
View File

@ -0,0 +1,60 @@
# {{.Name}} Compliance Program
This repository consolidates all documents related to the {{.Name}} Compliance Program.
# Structure
Compliance documents are organized as follows:
```
narratives/ Narratives provide an overview of the organization and the compliance environment.
policies/ Policies govern the behavior of employees and contractors.
procedures/ Procedures prescribe specific steps that are taken in response to key events.
standards/ Standards specify the controls satisfied by the compliance program.
templates/ Templates control the output format of the HTML Dashboard and PDF assets.
```
# Building
Assets are built using [`comply`](https://comply.strongdm.com), which can be installed via `brew install comply` (macOS) or `go get github.com/strongdm/comply`
# Publishing
The `output/` directory contains all generated assets. Links in the HTML dashboard a relative, and all dependencies are included via direct CDN references. The entire `output/` directory therefore may be uploaded to an S3 bucket or other static asset host without further modification.
# Dashboard Status
Procedure tracking is updated whenever `comply sync` is invoked. Invoke a sync prior to `comply build` to include the most current ticket status.
# Procedure Scheduler
Any `procedures/` that include a `cron` schedule will automatically created in your configured ticketing system whenever `comply scheduler` is executed. The scheduler will backfill any overdue tickets.
# Deployment Recommendation
Invoke a script similar to the following at least once per day:
```
#!/bin/bash
#
# prerequisites:
# git access
# ticketing configuration in comply.yml
# upload.sh to publish static site
#
# get latest policies and procedures
git pull
# update ticketing status
comply sync
# trigger creation of scheduled tickets
comply scheduler
# build latest
comply build
# publish static site from output/ directory
upload.sh output/
```

37
example/TODO.md Normal file
View File

@ -0,0 +1,37 @@
# {{.Name}} Compliance Program
High-level TODO created by [comply](https://github.com/strongdm/comply)
## Initialization Phase (hours)
- [ ] Add project to source control
- [ ] Verify `comply build` generates valid output
- [ ] Create ticketing credentials, configure via `comply.yml`
- [ ] Verify `comply sync` executes without errors
## Authoring Phase (weeks)
- [ ] Validate standards/, pruning as necessary
- [ ] Customize narratives/
- [ ] Customize policies/
- [ ] Distribute controls among policies
- [ ] Ensure policies address all controls
- [ ] Customize procedures/
- [ ] Distribute controls among procedures
- [ ] Create valid ticket templates
- [ ] Assign schedules
- [ ] Verify `comply todo` indicates all controls satisfied
## Deployment Phase (weeks)
- [ ] Deploy `comply scheduler` (see README.md for example script)
- [ ] Deploy `comply build` output to shared location
- [ ] Distribute policies to team
- [ ] Train team on use of ticketing system to designate compliance-relevant activity
## Operating Phase (eternal)
- [ ] Monitor timely ticket workflow
- [ ] Adjust and re-publish narratives, policies and procedures as necessary
## Audit Phase (weeks, annually)
- [ ] Import request list (tickets will be generated)
- [ ] Fulfill all request tickets
- [ ] Attach policies, procedures, and narratives
- [ ] Attach evidence collected by previously-executed procedure tickets

View File

@ -0,0 +1,7 @@
name: "Acme"
filePrefix: "Acme"
tickets:
github:
token: XXX
username: strongdm
repo: comply

View File

@ -0,0 +1,3 @@
# Narratives
Narratives provide an overview of the organization and the compliance environment.

View File

@ -0,0 +1,22 @@
name: Control Environment Narrative
acronym: CEN
satisfies:
TSC:
- CC2.1
- CC2.2
- CC2.3
- CC4.1
- CC4.2
- CC5.1
- CC5.2
- CC5.3
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Control Environment Narrative
Here we narrate why our control environment satisfies the control keys listed in the YML block
# Template Coming Soon

View File

@ -0,0 +1,22 @@
name: Organizational Narrative
acronym: ON
satisfies:
TSC:
- CC1.2
- CC1.3
- CC1.4
- CC1.5
- CC3.1
- CC3.2
- CC3.3
- CC3.4
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Organizational Narrative
Here we narrate why our org satisfies the control keys listed in the YML block
# Template Coming Soon

View File

@ -0,0 +1,12 @@
name: Products and Services Narrative
acronym: PSN
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Products Narrative
Here we describe the key products marketed by our organization
# Template Coming Soon

View File

@ -0,0 +1,18 @@
name: Security Architecture Narrative
acronym: SEN
satisfies:
TSC:
- CC6.6
- CC6.7
- CC7.1
- CC7.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Security Architecture Narrative
Here we narrate why our org satisfies the control keys listed in the YML block
# Template Coming Soon

View File

@ -0,0 +1,12 @@
name: System Architecture Narrative
acronym: SAN
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# System Architecture Narrative
Here we narrate why our org satisfies the control keys listed in the YML block
# Template Coming Soon

View File

@ -0,0 +1,3 @@
# Policies
Policies govern the behavior of employees and contractors.

View File

@ -0,0 +1,13 @@
name: Access Onboarding and Termination Policy
acronym: AOTP
satisfies:
TSC:
- CC6.1
- CC6.2
- CC6.3
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Application Security Policy
acronym: ASP
satisfies:
TSC:
- CC6.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,12 @@
name: Availability Policy
acronym: AP
satisfies:
TSC:
- A1.1
- CC9.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: System Change Policy
acronym: SCP
satisfies:
TSC:
- CC8.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Data Classification Policy
acronym: DCP
satisfies:
TSC:
- CC9.9
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Code of Conduct Policy
acronym: COCP
satisfies:
TSC:
- CC1.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,12 @@
name: Confidentiality Policy
acronym: CP
satisfies:
TSC:
- C1.1
- C1.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Business Continuity Policy
acronym: BCP
satisfies:
TSC:
- CC9.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

11
example/policies/cyber.md Normal file
View File

@ -0,0 +1,11 @@
name: Cyber Risk Assessment Policy
acronym: CRP
satisfies:
TSC:
- CC9.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Datacenter Policy
acronym: DP
satisfies:
TSC:
- CC6.4
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Software Development Lifecycle Policy
acronym: SDLCP
satisfies:
TSC:
- CC8.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,12 @@
name: Disaster Recovery Policy
acronym: DRP
satisfies:
TSC:
- A1.2
- A1.3
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Encryption Policy
acronym: EP
satisfies:
TSC:
- CC9.9
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,13 @@
name: Security Incident Response Policy
acronym: SIRP
satisfies:
TSC:
- CC7.3
- CC7.4
- CC7.5
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Information Security Policy
acronym: ISP
satisfies:
TSC:
- CC9.9
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

11
example/policies/log.md Normal file
View File

@ -0,0 +1,11 @@
name: Log Management Policy
acronym: LMP
satisfies:
TSC:
- CC7.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

11
example/policies/media.md Normal file
View File

@ -0,0 +1,11 @@
name: Removable Media and Cloud Storage Policy
acronym: MCP
satisfies:
TSC:
- CC6.7
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Office Security Policy
acronym: OSP
satisfies:
TSC:
- CC6.4
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Password Policy
acronym: PWP
satisfies:
TSC:
- CC9.9
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Policy Training Policy
acronym: PTP
satisfies:
TSC:
- CC9.9
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,28 @@
name: Privacy Management Policy
acronym: PMP
satisfies:
TSC:
- P1.1
- P2.1
- P3.1
- P3.2
- P4.1
- P4.2
- P4.3
- P5.1
- P5.2
- P6.1
- P6.2
- P6.3
- P6.4
- P6.5
- P6.6
- P6.7
- P7.1
- P8.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,15 @@
name: Processing Integrity Policy
acronym: PIP
satisfies:
TSC:
- PI1.1
- PI1.2
- PI1.3
- PI1.4
- PI1.5
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,13 @@
name: Remote Access Policy
acronym: REAP
satisfies:
TSC:
- CC6.1
- CC6.2
- CC6.7
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,13 @@
name: Data Retention Policy
acronym: RP
satisfies:
TSC:
- CC1.2
- CC6.5
- P4.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

11
example/policies/risk.md Normal file
View File

@ -0,0 +1,11 @@
name: Risk Assessment Policy
acronym: RIAP
satisfies:
TSC:
- CC9.1
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Vendor Management Policy
acronym: VMP
satisfies:
TSC:
- CC9.2
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,11 @@
name: Workstation Policy
acronym: WP
satisfies:
TSC:
- CC6.8
majorRevisions:
- date: Jun 1 2018
comment: Initial document
---
# Coming Soon

View File

@ -0,0 +1,3 @@
# Procedures
Procedures prescribe specific steps that are taken in response to key events.

View File

@ -0,0 +1,5 @@
id: "offboard"
name: "Offboard User"
---
# Coming Soon

View File

@ -0,0 +1,5 @@
id: "onboard"
name: "Onboard New User"
---
# Coming Soon

View File

@ -0,0 +1,6 @@
id: "patch"
name: "Apply OS patches"
cron: "0 0 1 * * *"
---
# Coming Soon

View File

@ -0,0 +1,6 @@
id: "workstation"
name: "Collect Workstation Details"
cron: "0 0 * * * *"
---
# Coming Soon

View File

@ -0,0 +1,5 @@
# Standards
All `yaml` files in this directory are assumed to conform to https://github.com/opencontrol/schemas/tree/master/kwalify/standard
Adjust the target standard for this project by adding or removing line-items within each file, or adding/removing a standard file entirely.

View File

@ -0,0 +1,245 @@
name: TSC
CC1.1:
family: CC1
name: Integrity and Ethics
description: The entity demonstrates a commitment to integrity and ethical values
CC1.2:
family: CC1
name: Board Independence
description: The board of directors demonstrates independence from management and exercises oversight of the development and performance of internal control
CC1.3:
family: CC1
name: Organizational Structure
description: Management establishes, with board oversight, structures, reporting lines, and appropriate authorities and responsibilities in the pursuit of objectives
CC1.4:
family: CC1
name: Hiring, Training and Retention
description: The entity demonstrates a commitment to attract, develop, and retain competent individuals in alignment with objectives
CC1.5:
family: CC1
name: Individual Accountability
description: The entity holds individuals accountable for their internal control responsibilities in the pursuit of objectives.
CC2.1:
family: CC2
name: Use of Information Systems
description: The entity obtains or generates and uses relevant, quality information to support the functioning of internal control
CC2.2:
family: CC2
name: Use of Communication Systems, Internal
description: The entity internally communicates information, including objectives and responsibilities for internal control, necessary to support the functioning of internal control
CC2.3:
family: CC2
name: Use of Communication Systems, External
description: The entity communicates with external parties regarding matters affecting the functioning of internal control
CC3.1:
family: CC3
name: Objectives
description: The entity specifies objectives with sufficient clarity to enable the identification and assessment of risks relating to objectives
CC3.2:
family: CC3
name: Risk to Objectives
description: The entity identifies risks to the achievement of its objectives across the entity and analyzes risks as a basis for determining how the risks should be managed
CC3.3:
family: CC3
name: Fraud Risk to Objectives
description: The entity considers the potential for fraud in assessing risks to the achievement of objectives
CC3.4:
family: CC3
name: Impact of Changes
description: The entity identifies and assesses changes that could significantly impact the system of internal control
CC4.1:
family: CC4
name: Monitoring
description: The entity selects, develops, and performs ongoing and/or separate evaluations to ascertain whether the components of internal control are present and functioning
CC4.2:
family: CC4
name: Remediation
description: The entity evaluates and communicates internal control deficiencies in a timely manner to those parties responsible for taking corrective action, including senior management and the board of directors, as appropriate
CC5.1:
family: CC5
name: Objective Risk Mitigation
description: The entity selects and develops control activities that contribute to the mitigation of risks to the achievement of objectives to acceptable levels
CC5.2:
family: CC5
name: Technology Controls
description: The entity also selects and develops general control activities over technology to support the achievement of objectives
CC5.3:
family: CC5
name: Established Policies
description: The entity deploys control activities through policies that establish what is expected and in procedures that put policies into action
CC6.1:
family: CC6
name: Logical Access
description: The entity implements logical access security software, infrastructure, and architectures over protected information assets to protect them from security events to meet the entitys objectives
CC6.2:
family: CC6
name: User Access
description: Prior to issuing system credentials and granting system access, the entity registers and authorizes new internal and external users whose access is administered by the entity. For those users whose access is administered by the entity, user system credentials are removed when user access is no longer authorized
CC6.3:
family: CC6
name: Role-Based Access
description: The entity authorizes, modifies, or removes access to data, software, functions, and other protected information assets based on roles, responsibilities, or the system design and changes, giving consideration to the concepts of least privilege and segregation of duties, to meet the entitys objectives
CC6.4:
family: CC6
name: Physical Access
description: The entity restricts physical access to facilities and protected information assets (for example, data center facilities, back-up media storage, and other sensitive locations) to authorized personnel to meet the entitys objectives
CC6.5:
family: CC6
name: Data Disposal
description: The entity discontinues logical and physical protections over physical assets only after the ability to read or recover data and software from those assets has been diminished and is no longer required to meet the entitys objectives
CC6.6:
family: CC6
name: External Threats
description: The entity implements logical access security measures to protect against threats from sources outside its system boundaries
CC6.7:
family: CC6
name: Data Custody and Transmission
description: The entity restricts the transmission, movement, and removal of information to authorized internal and external users and processes, and protects it during transmission, movement, or removal to meet the entitys objectives
CC6.8:
family: CC6
name: Malware Detection
description: The entity implements controls to prevent or detect and act upon the introduction of unauthorized or malicious software to meet the entitys objectives
CC7.1:
family: CC7
name: Vulnerability Detection
description: To meet its objectives, the entity uses detection and monitoring procedures to identify (1) changes to configurations that result in the introduction of new vulnerabilities, and (2) susceptibilities to newly discovered vulnerabilities
CC7.2:
family: CC7
name: Anomaly Detection
description: The entity monitors system components and the operation of those components for anomalies that are indicative of malicious acts, natural disasters, and errors affecting the entitys ability to meet its objectives; anomalies are analyzed to determine whether they represent security events
CC7.3:
family: CC7
name: Security Incident Evaluation
description: The entity evaluates security events to determine whether they could or have resulted in a failure of the entity to meet its objectives (security incidents) and, if so, takes actions to prevent or address such failures
CC7.4:
family: CC7
name: Security Incident Response Plan
description: The entity responds to identified security incidents by executing a defined incident response program to understand, contain, remediate, and communicate security incidents, as appropriate
CC7.5:
family: CC7
name: Security Incident Response Execution
description: The entity identifies, develops, and implements activities to recover from identified security incidents
CC8.1:
family: CC8
name: Change Control
description: The entity authorizes, designs, develops or acquires, configures, documents, tests, approves, and implements changes to infrastructure, data, software, and procedures to meet its objectives
CC9.1:
family: CC9
name: Disruption Risk Mitigation
description: The entity identifies, selects, and develops risk mitigation activities for risks arising from potential business disruptions
CC9.2:
family: CC9
name: Vendor Risk Management
description: The entity assesses and manages risks associated with vendors and business partners
A1.1:
family: A1
name: Capacity Planning
description: The entity maintains, monitors, and evaluates current processing capacity and use of system components (infrastructure, data, and software) to manage capacity demand and to enable the implementation of additional capacity to help meet its objectives
A1.2:
family: A1
name: Backup and Recovery
description: The entity authorizes, designs, develops or acquires, implements, operates, approves, maintains, and monitors environmental protections, software, data back-up processes, and recovery infrastructure to meet its objectives
A1.3:
family: A1
name: Recovery Testing
description: The entity tests recovery plan procedures supporting system recovery to meet its objectives
C1.1:
family: C1
name: Confidential Information Identification
description: The entity identifies and maintains confidential information to meet the entitys objectives related to confidentiality
C1.2:
family: C1
name: Confidential Information Disposal
description: The entity disposes of confidential information to meet the entitys objectives related to confidentiality.
PI1.1:
family: PI1
name: Processing Integrity Monitoring
description: The entity obtains or generates, uses, and communicates relevant, quality information regarding the objectives related to processing, including de nitions of data processed and product and service speci cations, to support the use of products and services
PI1.2:
family: PI1
name: Processing Integrity Accuracy
description: The entity implements policies and procedures over system inputs, including controls over completeness and accuracy, to result in products, services, and reporting to meet the entitys objectives
PI1.3:
family: PI1
name: Processing Integrity Operations
description: The entity implements policies and procedures over system processing to result in products, services, and reporting to meet the entitys objectives
PI1.4:
family: PI1
name: Processing Integrity Outputs
description: The entity implements policies and procedures to make available or deliver output completely, accurately, and timely in accordance with speci cations to meet the entitys objectives
PI1.5:
family: PI1
name: Processing Integrity Backups
description: The entity implements policies and procedures to store inputs, items in processing, and outputs completely, accurately, and timely in accordance with system speci cations to meet the entitys objectives
P1.1:
family: P1
name: Privacy Notification
description: The entity provides notice to data subjects about its privacy practices to meet the entitys objectives related to privacy. The notice is updated and communicated to data subjects in a timely manner for changes to the entitys privacy practices, including changes in the use of personal information, to meet the entitys objectives related to privacy
P2.1:
family: P2
name: Privacy Consent and Choice
description: The entity communicates choices available regarding the collection, use, retention, disclosure, and disposal of personal information to the data subjects and the consequences, if any, of each choice. Explicit consent for the collection, use, retention, disclosure, and disposal of personal information is obtained from data subjects or other authorized persons, if required. Such consent is obtained only for the intended purpose of the information to meet the entitys objectives related to privacy. The entitys basis for determining implicit consent for the collection, use, retention, disclosure, and disposal of personal information is documented
P3.1:
family: P3
name: Personal Information Collection
description: Personal information is collected consistent with the entitys objectives related to privacy
P3.2:
family: P3
name: Explicit Consent
description: For information requiring explicit consent, the entity communicates the need for such consent, as well as the consequences of a failure to provide consent for the request for personal information, and obtains the consent prior to the collection of the information to meet the entitys objectives related to privacy
P4.1:
family: P4
name: Proper Use of Personal Information
description: The entity limits the use of personal information to the purposes identified in the entitys objectives related to privacy
P4.2:
family: P4
name: Personal Information Retention
description: The entity retains personal information consistent with the entitys objectives related to privacy
P4.3:
family: P4
name: Personal Information Disposal
description: The entity securely disposes of personal information to meet the entitys objectives related to privacy
P5.1:
family: P5
name: Data Subject Access
description: The entity grants identified and authenticated data subjects the ability to access their stored personal information for review and, upon request, provides physical or electronic copies of that information to data subjects to meet the entitys objectives related to privacy. If access is denied, data subjects are informed of the denial and reason for such denial, as required, to meet the entitys objectives related to privacy
P5.2:
family: P5
name: Data Subject Amendment
description: The entity corrects, amends, or appends personal information based on information provided by data subjects and communicates such information to third parties, as committed or required, to meet the entitys objectives related to privacy. If a request for correction is denied, data subjects are informed of the denial and reason for such denial to meet the entitys objectives related to privacy
P6.1:
family: P6
name: Consent for Third Party Disclosure
description: The entity discloses personal information to third parties with the explicit consent of data subjects, and such consent is obtained prior to disclosure to meet the entitys objectives related to privacy
P6.2:
family: P6
name: Authorized Disclosures
description: The entity creates and retains a complete, accurate, and timely record of authorized disclosures of personal information to meet the entitys objectives related to privacy
P6.3:
family: P6
name: Unauthorized Disclosures
description: The entity creates and retains a complete, accurate, and timely record of detected or reported unauthorized disclosures (including breaches) of personal information to meet the entitys objectives related to privacy
P6.4:
family: P6
name: Appropriate Third Party Disclosure
description: The entity obtains privacy commitments from vendors and other third parties who have access to personal information to meet the entitys objectives related to privacy. The entity assesses those parties compliance on a periodic and as-needed basis and takes corrective action, if necessary
P6.5:
family: P6
name: Unauthorized Third Party Disclosure
description: The entity obtains commitments from vendors and other third parties with access to personal information to notify the entity in the event of actual or suspected unauthorized disclosures of personal information. Such notifications are reported to appropriate personnel and acted on in accordance with established incident response procedures to meet the entitys objectives related to privacy
P6.6:
family: P6
name: Notification of Unauthorized Third Party Disclosure
description: The entity provides notification of breaches and incidents to affected data subjects, regulators, and others to meet the entitys objectives related to privacy
P6.7:
family: P6
name: Accounting of Personal Information
description: The entity provides data subjects with an accounting of the personal information held and disclosure of the data subjects personal information, upon the data subjects request, to meet the entitys objectives related to privacy
P7.1:
family: P7
name: Accuracy of Personal Information
description: The entity collects and maintains accurate, up-to-date, complete, and relevant personal information to meet the entitys objectives related to privacy
P8.1:
family: P8
name: Personal Information Dispute Resolution
description: The entity implements a process for receiving, addressing, resolving, and communicating the resolution of inquiries, complaints, and disputes from data subjects and others and periodically monitors compliance to meet the entitys objectives related to privacy. Corrections and other necessary actions related to identified deficiencies are made or taken in a timely manner

View File

@ -0,0 +1,280 @@
\documentclass[$if(fontsize)$$fontsize$,$endif$$if(lang)$$babel-lang$,$endif$$if(papersize)$$papersize$paper,$endif$$for(classoption)$$classoption$$sep$,$endfor$]{$documentclass$}
$if(beamerarticle)$
\usepackage{beamerarticle} % needs to be loaded first
$endif$
$if(fontfamily)$
\usepackage[$for(fontfamilyoptions)$$fontfamilyoptions$$sep$,$endfor$]{$fontfamily$}
$else$
\usepackage{lmodern}
$endif$
$if(linestretch)$
\usepackage{setspace}
\setstretch{$linestretch$}
$endif$
\usepackage{amssymb,amsmath}
\usepackage{ifxetex,ifluatex}
\usepackage{fixltx2e} % provides \textsubscript
\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex
\usepackage[$if(fontenc)$$fontenc$$else$T1$endif$]{fontenc}
\usepackage[utf8]{inputenc}
$if(euro)$
\usepackage{eurosym}
$endif$
\else % if luatex or xelatex
\ifxetex
\usepackage{mathspec}
\else
\usepackage{fontspec}
\fi
\defaultfontfeatures{Ligatures=TeX,Scale=MatchLowercase}
$for(fontfamilies)$
\newfontfamily{$fontfamilies.name$}[$fontfamilies.options$]{$fontfamilies.font$}
$endfor$
$if(euro)$
\newcommand{\euro}{€}
$endif$
$if(mainfont)$
\setmainfont[$for(mainfontoptions)$$mainfontoptions$$sep$,$endfor$]{$mainfont$}
$endif$
$if(sansfont)$
\setsansfont[$for(sansfontoptions)$$sansfontoptions$$sep$,$endfor$]{$sansfont$}
$endif$
$if(monofont)$
\setmonofont[Mapping=tex-ansi$if(monofontoptions)$,$for(monofontoptions)$$monofontoptions$$sep$,$endfor$$endif$]{$monofont$}
$endif$
$if(mathfont)$
\setmathfont(Digits,Latin,Greek)[$for(mathfontoptions)$$mathfontoptions$$sep$,$endfor$]{$mathfont$}
$endif$
$if(CJKmainfont)$
\usepackage{xeCJK}
\setCJKmainfont[$for(CJKoptions)$$CJKoptions$$sep$,$endfor$]{$CJKmainfont$}
$endif$
\fi
% use upquote if available, for straight quotes in verbatim environments
\IfFileExists{upquote.sty}{\usepackage{upquote}}{}
% use microtype if available
\IfFileExists{microtype.sty}{%
\usepackage[$for(microtypeoptions)$$microtypeoptions$$sep$,$endfor$]{microtype}
\UseMicrotypeSet[protrusion]{basicmath} % disable protrusion for tt fonts
}{}
\PassOptionsToPackage{hyphens}{url} % url is loaded by hyperref
$if(verbatim-in-note)$
\usepackage{fancyvrb}
$endif$
\usepackage[unicode=true]{hyperref}
$if(colorlinks)$
\PassOptionsToPackage{usenames,dvipsnames}{color} % color is loaded by hyperref
$endif$
\hypersetup{
$if(title-meta)$
pdftitle={$title-meta$},
$endif$
$if(author-meta)$
pdfauthor={$author-meta$},
$endif$
$if(keywords)$
pdfkeywords={$for(keywords)$$keywords$$sep$, $endfor$},
$endif$
$if(colorlinks)$
colorlinks=true,
linkcolor=$if(linkcolor)$$linkcolor$$else$Maroon$endif$,
citecolor=$if(citecolor)$$citecolor$$else$Blue$endif$,
urlcolor=$if(urlcolor)$$urlcolor$$else$Blue$endif$,
$else$
pdfborder={0 0 0},
$endif$
breaklinks=true}
\urlstyle{same} % don't use monospace font for urls
$if(verbatim-in-note)$
\VerbatimFootnotes % allows verbatim text in footnotes
$endif$
$if(geometry)$
\usepackage[$for(geometry)$$geometry$$sep$,$endfor$]{geometry}
$endif$
$if(lang)$
\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex
\usepackage[shorthands=off,$for(babel-otherlangs)$$babel-otherlangs$,$endfor$main=$babel-lang$]{babel}
$if(babel-newcommands)$
$babel-newcommands$
$endif$
\else
\usepackage{polyglossia}
\setmainlanguage[$polyglossia-lang.options$]{$polyglossia-lang.name$}
$for(polyglossia-otherlangs)$
\setotherlanguage[$polyglossia-otherlangs.options$]{$polyglossia-otherlangs.name$}
$endfor$
\fi
$endif$
$if(natbib)$
\usepackage{natbib}
\bibliographystyle{$if(biblio-style)$$biblio-style$$else$plainnat$endif$}
$endif$
$if(biblatex)$
\usepackage[$if(biblio-style)$style=$biblio-style$,$endif$$for(biblatexoptions)$$biblatexoptions$$sep$,$endfor$]{biblatex}
$for(bibliography)$
\addbibresource{$bibliography$}
$endfor$
$endif$
$if(listings)$
\usepackage{listings}
$endif$
$if(lhs)$
\lstnewenvironment{code}{\lstset{language=Haskell,basicstyle=\small\ttfamily}}{}
$endif$
$if(highlighting-macros)$
$highlighting-macros$
$endif$
$if(tables)$
\usepackage{longtable,booktabs}
% Fix footnotes in tables (requires footnote package)
\IfFileExists{footnote.sty}{\usepackage{footnote}\makesavenoteenv{long table}}{}
$endif$
$if(graphics)$
\usepackage{graphicx,grffile}
\makeatletter
\def\maxwidth{\ifdim\Gin@nat@width>\linewidth\linewidth\else\Gin@nat@width\fi}
\def\maxheight{\ifdim\Gin@nat@height>\textheight\textheight\else\Gin@nat@height\fi}
\makeatother
% Scale images if necessary, so that they will not overflow the page
% margins by default, and it is still possible to overwrite the defaults
% using explicit options in \includegraphics[width, height, ...]{}
\setkeys{Gin}{width=\maxwidth,height=\maxheight,keepaspectratio}
$endif$
$if(links-as-notes)$
% Make links footnotes instead of hotlinks:
\renewcommand{\href}[2]{#2\footnote{\url{#1}}}
$endif$
$if(strikeout)$
\usepackage[normalem]{ulem}
% avoid problems with \sout in headers with hyperref:
\pdfstringdefDisableCommands{\renewcommand{\sout}{}}
$endif$
$if(indent)$
$else$
\IfFileExists{parskip.sty}{%
\usepackage{parskip}
}{% else
\setlength{\parindent}{0pt}
\setlength{\parskip}{6pt plus 2pt minus 1pt}
}
$endif$
\setlength{\emergencystretch}{3em} % prevent overfull lines
\providecommand{\tightlist}{%
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
$if(numbersections)$
\setcounter{secnumdepth}{$if(secnumdepth)$$secnumdepth$$else$5$endif$}
$else$
\setcounter{secnumdepth}{0}
$endif$
$if(subparagraph)$
$else$
% Redefines (sub)paragraphs to behave more like sections
\ifx\paragraph\undefined\else
\let\oldparagraph\paragraph
\renewcommand{\paragraph}[1]{\oldparagraph{#1}\mbox{}}
\fi
\ifx\subparagraph\undefined\else
\let\oldsubparagraph\subparagraph
\renewcommand{\subparagraph}[1]{\oldsubparagraph{#1}\mbox{}}
\fi
$endif$
$if(dir)$
\ifxetex
% load bidi as late as possible as it modifies e.g. graphicx
$if(latex-dir-rtl)$
\usepackage[RTLdocument]{bidi}
$else$
\usepackage{bidi}
$endif$
\fi
\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex
\TeXXeTstate=1
\newcommand{\RL}[1]{\beginR #1\endR}
\newcommand{\LR}[1]{\beginL #1\endL}
\newenvironment{RTL}{\beginR}{\endR}
\newenvironment{LTR}{\beginL}{\endL}
\fi
$endif$
% set default figure placement to htbp
\makeatletter
\def\fps@figure{htbp}
\makeatother
$if(header-includes)$
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhead{}
\fancyhead[RO,RE]{$head-content$}
\fancyfoot[LO,LE]{$foot-content$}
$endif$
$if(title)$
\title{$title$$if(thanks)$\thanks{$thanks$}$endif$}
$endif$
$if(subtitle)$
\providecommand{\subtitle}[1]{}
\subtitle{$subtitle$}
$endif$
$if(author)$
\author{$for(author)$$author$$sep$ \and $endfor$}
$endif$
$if(institute)$
\providecommand{\institute}[1]{}
\institute{$for(institute)$$institute$$sep$ \and $endfor$}
$endif$
\date{$date$}
\begin{document}
$if(title)$
\maketitle
$endif$
$if(abstract)$
\begin{abstract}
$abstract$
\end{abstract}
$endif$
$for(include-before)$
$include-before$
$endfor$
$if(toc)$
{
$if(colorlinks)$
\hypersetup{linkcolor=$if(toccolor)$$toccolor$$else$black$endif$}
$endif$
\setcounter{tocdepth}{$toc-depth$}
\tableofcontents
}
$endif$
$if(lot)$
\listoftables
$endif$
$if(lof)$
\listoffigures
$endif$
$body$
$if(natbib)$
$if(bibliography)$
$if(biblio-title)$
$if(book-class)$
\renewcommand\bibname{$biblio-title$}
$else$
\renewcommand\refname{$biblio-title$}
$endif$
$endif$
\bibliography{$for(bibliography)$$bibliography$$sep$,$endfor$}
$endif$
$endif$
$if(biblatex)$
\printbibliography$if(biblio-title)$[title=$biblio-title$]$endif$
$endif$
$for(include-after)$
$include-after$
$endfor$
\end{document}

224
example/templates/index.ace Normal file
View File

@ -0,0 +1,224 @@
= doctype html
html lang=en
head
meta charset=utf-8
title {{.Project.Name}}
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.2/css/bulma.min.css"
link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulmaswatch/0.6.2/sandstone/bulmaswatch.min.css"
script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.22.0/moment.min.js"
script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/later/1.2.0/later.min.js"
script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/prettycron/0.11.0/prettycron.min.js"
meta name="viewport" content="width=device-width, initial-scale=1"
= css
= javascript
document.addEventListener("DOMContentLoaded", function(event) {
document.querySelectorAll('.cron').forEach(function(el) {
el.innerHTML = prettyCron.toString(el.innerHTML)
})
})
function show(name) {
var items = document.getElementsByClassName('top-nav')
for (var i=0; i<items.length; i++) {
var item = items[i]
if (item.tagName === "LI") {
// navigation tabs
if (item.classList.contains(name)) {
item.classList.add('is-active')
} else {
item.classList.remove('is-active')
}
} else {
// sections
if (item.id === name) {
item.classList.remove('is-hidden')
} else {
item.classList.add('is-hidden')
}
}
}
}
body
section.hero.is-primary.is-small
.hero-body
.container
h1.title {{.Project.Name}}
p.subtitle Policy, Procedure, and Audit Status
.hero-foot
nav.tabs.is-boxed.is-fullwidth
.container
ul.is-size-4
li.top-nav.overview
strong
a onclick="javascript:show('overview')" Overview
li.top-nav.narratives
strong
a onclick="javascript:show('narratives')" Narratives
li.top-nav.policies
strong
a onclick="javascript:show('policies')" Policies
li.top-nav.procedures
strong
a onclick="javascript:show('procedures')" Procedures
li.top-nav.standards
strong
a onclick="javascript:show('standards')" Standards
/ li.top-nav.evidence
/ a onclick="javascript:show('evidence')" Evidence Vault
#overview.section.top-nav.container.content
blockquote
h3 This site consolidates all documents related to the {{.Project.Name}}
hr
.columns.is-vcentered
.column.is-one-third
div
p.subtitle.is-3.has-text-centered Control Tracking
.column.has-text-centered
div
p.heading Satisfied Controls
p.title
{{.Stats.ControlsSatisfied}}
.column.has-text-centered
div
p.heading Total Controls
p.title
{{.Stats.ControlsTotal}}
.columns.is-vcentered
.column.is-one-third
div
p.subtitle.is-3.has-text-centered Procedure Tracking
.column.has-text-centered
div
p.heading Active Tickets
p.title
a target=_blank href="{{.Links.ProcedureOpen}}"
{{.Stats.ProcedureOpen}}
.column.has-text-centered
div
p.heading Oldest Ticket
p.title
a {{.Stats.ProcedureOldestDays}} days
.columns.is-vcentered
.column.is-one-third
div.has-text-centered
p.subtitle.is-3 Audit Tracking
.column.has-text-centered
div
p.heading Open Requests
p.title
a target=_blank href="{{.Links.AuditOpen}}"
{{.Stats.AuditOpen}}
.column.has-text-centered
div
p.heading Total Requests
p.title
a target=_blank href="{{.Links.AuditAll}}"
{{.Stats.AuditTotal}}
.columns.is-vcentered
.column.is-one-third
.column.is-two-thirds.has-text-centered
/ progress.progress.is-primary value={{.Stats.AuditClosed}} max={{.Stats.AuditTotal}}
#narratives.section.top-nav.container.content
blockquote
h3
p
strong Narratives
| provide an overview of the organization and the compliance environment.
table.table.is-size-4
thead
tr
th Name
th Acronym
th PDF
tbody
{{range .Narratives }}
tr
td {{.Name}}
td {{.Acronym}}
td
a href={{.OutputFilename}} target=_blank
{{.OutputFilename}}
{{end}}
#policies.section.top-nav.container.content
blockquote
h3
p
strong Policies
| govern the behavior of {{.Name}} employees and contractors.
table.table.is-size-4
thead
tr
th Name
th Acronym
th PDF
tbody
{{range .Policies }}
tr
td {{.Name}}
td {{.Acronym}}
td
a href={{.OutputFilename}} target=_blank
{{.OutputFilename}}
{{end}}
#procedures.section.top-nav.container.content
blockquote
h3
p
strong Procedures
| prescribe specific steps that are taken in response to key events.
table.table.is-size-4
thead
tr
th Name
th ID
th Schedule (cron format)
tbody
{{range .Procedures }}
tr
td {{.Name}}
td {{.ID}}
{{if .Cron}}
td.cron {{.Cron}}
{{else}}
td On demand
{{end}}
{{end}}
#standards.section.top-nav.container.content
blockquote
h3
p
strong Standards
| specify the controls satisfied by the compliance program.
table.table.is-size-4.is-fullwidth
thead
tr
th Control Key
th Name
th Satisfied?
th Satisfied By
tbody
{{range .Controls }}
tr
td {{.ControlKey}}
td
strong {{.Name}}
.subtitle {{.Description}}
{{if .Satisfied}}
td.is-success Yes
{{else}}
td No
{{end}}
td
{{range .SatisfiedBy}}
a.is-size-7 href={{.}} target=_blank
{{.}}
{{end}}
{{end}}
footer.footer
.container
.content.has-text-centered
p {{.Project.OrganizationName}} Confidential 2018
= javascript
// commented for development
show('overview')

177
internal/cli/app.go Normal file
View File

@ -0,0 +1,177 @@
package cli
import (
"context"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/plugin/github"
"github.com/urfave/cli"
)
// Version is set by the build system.
var Version = ""
// Main should be invoked by the main function in the main package.
func Main() {
err := newApp().Run(os.Args)
if err != nil {
log.Fatal(err)
}
}
func newApp() *cli.App {
app := cli.NewApp()
app.Name = "comply"
if Version == "" {
app.HideVersion = true
}
app.Version = Version
app.Usage = "policy compliance toolkit"
app.Commands = []cli.Command{
initCommand,
}
app.Commands = append(app.Commands, beforeCommand(buildCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(schedulerCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(serveCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(syncCommand, projectMustExist))
app.Commands = append(app.Commands, beforeCommand(todoCommand, projectMustExist))
// Plugins
github.Register()
return app
}
func beforeCommand(c cli.Command, bf ...cli.BeforeFunc) cli.Command {
if c.Before == nil {
c.Before = beforeAll(bf...)
} else {
c.Before = beforeAll(append(bf, c.Before)...)
}
return c
}
func beforeAll(bf ...cli.BeforeFunc) cli.BeforeFunc {
return func(c *cli.Context) error {
for _, f := range bf {
if err := f(c); err != nil {
return err
}
}
return nil
}
}
func feedbackError(message string) error {
return errors.New(fmt.Sprintf("\n\nERROR\n=====\n%s\n", message))
}
func projectMustExist(c *cli.Context) error {
_, err := ioutil.ReadFile(filepath.Join(config.ProjectRoot(), "comply.yml"))
if err != nil {
return feedbackError("command must be run from the root of a valid comply project (comply.yml must exist; have you run `comply init`?)")
}
return nil
}
func ticketingMustBeConfigured(c *cli.Context) error {
p := config.Config()
if p.Tickets == nil || len(p.Tickets) != 1 {
return feedbackError("comply.yml must contain a valid ticketing configuration")
}
return nil
}
func dockerMustExist(c *cli.Context) error {
dockerErr := fmt.Errorf("Docker must be available in order to run `%s`", c.Command.Name)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
return dockerErr
}
done := make(chan struct{})
defer close(done)
go func() {
// if docker IO takes more than N seconds, notify user we're (likely) downloading the pandoc image
longishPull := time.After(time.Second * 5)
select {
case <-longishPull:
fmt.Print("Pulling strongdm/pandoc:latest Docker image (this will take some time) ")
go func() {
for {
fmt.Print(".")
select {
case <-done:
fmt.Print(" done.\n")
return
default:
time.Sleep(1 * time.Second)
}
}
}()
case <-done:
// in this case, the docker pull was quick -- suggesting we already have the container
}
}()
r, err := cli.ImagePull(ctx, "strongdm/pandoc:latest", types.ImagePullOptions{})
if err != nil {
return dockerErr
}
defer r.Close()
// hold function open until all docker IO is complete
io.Copy(ioutil.Discard, r)
return nil
}
func cleanContainers(c *cli.Context) error {
dockerErr := fmt.Errorf("Docker must be available in order to run `%s`", c.Command.Name)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
return dockerErr
}
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{All: true})
if err != nil {
return errors.Wrap(err, "error listing containers during cleanup")
}
for _, c := range containers {
// assume this container was leftover from previous aborted run
if strings.HasPrefix(c.Image, "strongdm/pandoc") {
d := time.Second * 2
err = cli.ContainerStop(ctx, c.ID, &d)
if err != nil {
fmt.Printf("Unable to stop container ID %s\n", c.ID)
}
err = cli.ContainerRemove(ctx, c.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
fmt.Printf("Unable to remove container ID %s, please attempt manual removal\n", c.ID)
}
}
}
return nil
}

23
internal/cli/build.go Normal file
View File

@ -0,0 +1,23 @@
package cli
import (
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/render"
"github.com/urfave/cli"
)
var buildCommand = cli.Command{
Name: "build",
ShortName: "b",
Usage: "generate a static website summarizing the compliance program",
Action: buildAction,
Before: beforeAll(dockerMustExist, cleanContainers),
}
func buildAction(c *cli.Context) error {
err := render.Build("output", false)
if err != nil {
return errors.Wrap(err, "build failed")
}
return nil
}

4
internal/cli/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package cli defines comply commands and arguments.
*/
package cli

184
internal/cli/init.go Normal file
View File

@ -0,0 +1,184 @@
package cli
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/manifoldco/promptui"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
"github.com/strongdm/comply/internal/theme"
"github.com/urfave/cli"
"gopkg.in/yaml.v2"
)
const whatNow = `Next steps:
* Customize this directory using a text editor ('cat TODO.md' for ideas)
* Try 'comply build' and 'comply serve'
* View output/index.html
* Add this directory to source control
`
var initCommand = cli.Command{
Name: "init",
Usage: "initialize a new compliance repository (interactive)",
Action: initAction,
}
func initAction(c *cli.Context) error {
fi, _ := ioutil.ReadDir(config.ProjectRoot())
if len(fi) > 0 {
return errors.New("init must be run from an empty directory")
}
atLeast := func(n int) func(string) error {
return func(input string) error {
if len(input) < n {
return errors.New("Too short")
}
return nil
}
}
noSpaces := func(s string) error {
if strings.ContainsAny(s, "\n\t ") {
return errors.New("Must not contain spaces")
}
return nil
}
prompt := promptui.Prompt{
Label: "Organization Name",
Validate: atLeast(1),
}
name, err := prompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
prompt = promptui.Prompt{
Label: "PDF Filename Prefix (no spaces, no trailing separator)",
Default: strings.Split(name, " ")[0],
Validate: noSpaces,
}
prefix, err := prompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
chooser := promptui.Select{
Label: "Compliance Templates",
Items: []string{"SOC2", "Blank"},
}
choice, _, err := chooser.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
themeName := "comply-blank"
switch choice {
case 0:
themeName = "comply-soc2"
case 1:
themeName = "comply-blank"
default:
panic("unrecognized selection")
}
fmt.Printf("\nComply relies on your ticketing system for optional procedure tracking. You can always come back and enable this integration later.\n\n\n")
chooser = promptui.Select{
Label: "Ticket System",
Items: []string{"GitHub", "Jira", "None"},
}
choice, _, err = chooser.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
var tickets map[string]interface{}
ticketing := model.GitHub
switch choice {
case 0:
ticketing = model.GitHub
case 1:
fmt.Println("\nHello Jira user! The Jira ticketing plugin is currently in development, please join us on Slack for a status update.")
ticketing = model.NoTickets
default:
ticketing = model.NoTickets
}
if ticketing != model.NoTickets {
chooser = promptui.Select{
Label: "Configure ticketing system?",
Items: []string{fmt.Sprintf("Configure %s now", string(ticketing)), "Configure later (via comply.yml)"},
}
choice, _, err = chooser.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return err
}
tickets = make(map[string]interface{})
ticketConfig := make(map[string]string)
tickets[string(ticketing)] = ticketConfig
if choice == 0 {
plugin := model.GetPlugin(ticketing)
ticketPrompts := plugin.Prompts()
for k, prompt := range ticketPrompts {
p := promptui.Prompt{
Label: prompt,
Validate: atLeast(2),
}
v, err := p.Run()
if err != nil {
fmt.Printf("Prompt failed: %v\n", err)
return err
}
ticketConfig[k] = v
}
}
}
p := config.Project{}
p.Name = name
p.FilePrefix = prefix
p.Tickets = tickets
x, _ := yaml.Marshal(&p)
err = ioutil.WriteFile(filepath.Join(config.ProjectRoot(), "comply.yml"), x, os.FileMode(0644))
if err != nil {
return cli.NewExitError(err, 1)
}
replace := make(map[string]string)
replace["Name"] = p.Name
err = theme.SaveTo(themeName, replace, config.ProjectRoot())
if err != nil {
return cli.NewExitError(err, 1)
}
success := fmt.Sprintf("%s Compliance initialized successfully!", name)
fmt.Printf("%s %s\n\n", promptui.IconGood, success)
fmt.Println(whatNow)
return nil
}

21
internal/cli/scheduler.go Normal file
View File

@ -0,0 +1,21 @@
package cli
import (
"github.com/strongdm/comply/internal/ticket"
"github.com/urfave/cli"
)
var schedulerCommand = cli.Command{
Name: "scheduler",
Usage: "create tickets based on procedure schedule",
Action: schedulerAction,
Before: beforeAll(projectMustExist, ticketingMustBeConfigured),
}
func schedulerAction(c *cli.Context) error {
err := syncAction(c)
if err != nil {
return err
}
return ticket.TriggerScheduled()
}

22
internal/cli/serve.go Normal file
View File

@ -0,0 +1,22 @@
package cli
import (
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/render"
"github.com/urfave/cli"
)
var serveCommand = cli.Command{
Name: "serve",
Usage: "live updating version of the build command",
Action: serveAction,
Before: beforeAll(dockerMustExist, cleanContainers),
}
func serveAction(c *cli.Context) error {
err := render.Build("output", true)
if err != nil {
return errors.Wrap(err, "serve failed")
}
return nil
}

29
internal/cli/sync.go Normal file
View File

@ -0,0 +1,29 @@
package cli
import (
"github.com/strongdm/comply/internal/model"
"github.com/urfave/cli"
)
var syncCommand = cli.Command{
Name: "sync",
Usage: "sync ticket status to local cache",
Action: syncAction,
Before: beforeAll(projectMustExist, ticketingMustBeConfigured),
}
func syncAction(c *cli.Context) error {
// TODO: unhardcode plugin
tp := model.GetPlugin(model.GitHub)
tickets, err := tp.FindByTagName("comply")
if err != nil {
return err
}
for _, t := range tickets {
err = model.DB().Write("tickets", t.ID, t)
if err != nil {
return err
}
}
return nil
}

68
internal/cli/todo.go Normal file
View File

@ -0,0 +1,68 @@
package cli
import (
"os"
"sort"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter"
"github.com/strongdm/comply/internal/model"
"github.com/urfave/cli"
)
var todoCommand = cli.Command{
Name: "todo",
Usage: "list declared vs satisfied compliance controls",
Action: todoAction,
Before: projectMustExist,
}
func todoAction(c *cli.Context) error {
d, err := model.ReadData()
if err != nil {
return err
}
w := tablewriter.NewWriter(os.Stdout)
w.SetHeader([]string{"Standard", "Control", "Satisfied?", "Name"})
type row struct {
standard string
controlKey string
satisfied string
controlName string
}
satisfied := model.ControlsSatisfied(d)
var rows []row
for _, std := range d.Standards {
for id, c := range std.Controls {
sat := "NO"
if _, ok := satisfied[id]; ok {
sat = color.GreenString("YES")
}
rows = append(rows, row{
standard: std.Name,
controlKey: id,
satisfied: sat,
controlName: c.Name,
})
}
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].controlKey < rows[j].controlKey
})
w.SetAutoWrapText(false)
for _, r := range rows {
w.Append([]string{r.standard, r.controlKey, r.satisfied, r.controlName})
}
w.Render()
return nil
}

66
internal/config/config.go Normal file
View File

@ -0,0 +1,66 @@
package config
import (
"io/ioutil"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
)
var projectRoot string
// SetProjectRoot is used by the test suite.
func SetProjectRoot(dir string) {
projectRoot = dir
}
type Project struct {
Name string `yaml:"name"`
FilePrefix string `yaml:"filePrefix"`
Tickets map[string]interface{} `yaml:"tickets"`
}
// YAML is the parsed contents of ProjectRoot()/config.yml.
func YAML() map[interface{}]interface{} {
m := make(map[interface{}]interface{})
cfgBytes, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
panic("unable to load config.yml: " + err.Error())
}
yaml.Unmarshal(cfgBytes, &m)
return m
}
// Exists tests for the presence of a comply configuration file.
func Exists() bool {
_, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
return false
}
return true
}
// Config is the parsed contents of ProjectRoot()/config.yml.
func Config() Project {
p := Project{}
cfgBytes, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml"))
if err != nil {
panic("unable to load config.yml: " + err.Error())
}
yaml.Unmarshal(cfgBytes, &p)
return p
}
// ProjectRoot is the fully-qualified path to the root directory.
func ProjectRoot() string {
if projectRoot == "" {
dir, err := os.Getwd()
if err != nil {
panic(err)
}
projectRoot = dir
}
return projectRoot
}

4
internal/config/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package config provides access to the comply.yml file.
*/
package config

6
internal/model/audit.go Normal file
View File

@ -0,0 +1,6 @@
package model
type Audit struct {
ID string
Name string
}

36
internal/model/db.go Normal file
View File

@ -0,0 +1,36 @@
package model
import (
"os"
"path/filepath"
"sync"
"github.com/nanobox-io/golang-scribble"
"github.com/strongdm/comply/internal/config"
)
var dbSingletonOnce sync.Once
var dbSingleton *scribble.Driver
// DB provides a singleton reference to a local json cache; will panic if storage location is not writeable.
func DB() *scribble.Driver {
dbSingletonOnce.Do(func() {
if _, err := os.Stat(filepath.Join(config.ProjectRoot(), ".comply", "cache")); os.IsNotExist(err) {
err = os.Mkdir(filepath.Join(config.ProjectRoot(), ".comply"), os.FileMode(0755))
if err != nil {
panic("could not create directory .comply: " + err.Error())
}
err = os.Mkdir(filepath.Join(config.ProjectRoot(), ".comply", "cache"), os.FileMode(0755))
if err != nil {
panic("could not create directory .comply/cache: " + err.Error())
}
}
db, err := scribble.New(filepath.Join(config.ProjectRoot(), ".comply", "cache"), nil)
if err != nil {
panic("unable to load comply data: " + err.Error())
}
dbSingleton = db
})
return dbSingleton
}

35
internal/model/db_test.go Normal file
View File

@ -0,0 +1,35 @@
package model
import (
"os"
"path/filepath"
"testing"
"github.com/strongdm/comply/internal/config"
)
func TestSaveGet(t *testing.T) {
dir := os.TempDir()
config.SetProjectRoot(dir)
f, err := os.Create(filepath.Join(dir, "config.yml"))
if err != nil {
panic(err)
}
f.Close()
name := "Do something excellent"
err = DB().Write("tickets", "100", &Ticket{ID: "100", Name: name})
if err != nil {
panic(err)
}
ticket := &Ticket{}
err = DB().Read("tickets", "100", ticket)
if err != nil {
panic(err)
}
if ticket.Name != name {
t.Error("failed to read ticket")
}
}

14
internal/model/doc.go Normal file
View File

@ -0,0 +1,14 @@
/*
Package model defines the comply data model.
Markdown Wrappers
The model package treats typed markdown files as model objects. All wrapped markdown documents are assumed to have a YAML header and a markdown body separated by three dashes: "---".
Local Ticket Cache
Tickets are defined externally (in the configured ticketing system), and cached locally for rapid dashboard rendering.
*/
package model

188
internal/model/fs.go Normal file
View File

@ -0,0 +1,188 @@
package model
import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/path"
"gopkg.in/yaml.v2"
)
// ReadData loads all records from both the filesystem and ticket cache.
func ReadData() (*Data, error) {
tickets, err := ReadTickets()
if err != nil {
return nil, err
}
narratives, err := ReadNarratives()
if err != nil {
return nil, err
}
policies, err := ReadPolicies()
if err != nil {
return nil, err
}
procedures, err := ReadProcedures()
if err != nil {
return nil, err
}
standards, err := ReadStandards()
if err != nil {
return nil, err
}
return &Data{
Tickets: tickets,
Narratives: narratives,
Policies: policies,
Procedures: procedures,
Standards: standards,
}, nil
}
// ReadTickets returns all known tickets, or an empty list in the event the ticket cache is empty or unavailable.
func ReadTickets() ([]*Ticket, error) {
rt, err := DB().ReadAll("tickets")
if err != nil {
// empty list
return []*Ticket{}, nil
}
return tickets(rt)
}
func tickets(rawTickets []string) ([]*Ticket, error) {
var tickets []*Ticket
for _, rt := range rawTickets {
t := &Ticket{}
err := json.Unmarshal([]byte(rt), t)
if err != nil {
return nil, errors.Wrap(err, "malformed ticket JSON")
}
tickets = append(tickets, t)
}
return tickets, nil
}
// ReadStandards loads standard definitions from the filesystem.
func ReadStandards() ([]*Standard, error) {
var standards []*Standard
files, err := path.Standards()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
s := &Standard{}
sBytes, err := ioutil.ReadFile(f.FullPath)
if err != nil {
return nil, errors.Wrap(err, "unable to read "+f.FullPath)
}
yaml.Unmarshal(sBytes, &s)
standards = append(standards, s)
}
return standards, nil
}
// ReadNarratives loads narrative descriptions from the filesystem.
func ReadNarratives() ([]*Narrative, error) {
var narratives []*Narrative
files, err := path.Narratives()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
n := &Narrative{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &n)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
n.Body = mdmd.body
n.FullPath = f.FullPath
n.ModifiedAt = f.Info.ModTime()
n.OutputFilename = fmt.Sprintf("%s-%s.pdf", config.Config().FilePrefix, n.Acronym)
narratives = append(narratives, n)
}
return narratives, nil
}
// ReadProcedures loads procedure descriptions from the filesystem.
func ReadProcedures() ([]*Procedure, error) {
var procedures []*Procedure
files, err := path.Procedures()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
p := &Procedure{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &p)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
p.Body = mdmd.body
p.FullPath = f.FullPath
p.ModifiedAt = f.Info.ModTime()
procedures = append(procedures, p)
}
return procedures, nil
}
// ReadPolicies loads policy documents from the filesystem.
func ReadPolicies() ([]*Policy, error) {
var policies []*Policy
files, err := path.Policies()
if err != nil {
return nil, errors.Wrap(err, "unable to enumerate paths")
}
for _, f := range files {
p := &Policy{}
mdmd := loadMDMD(f.FullPath)
err = yaml.Unmarshal([]byte(mdmd.yaml), &p)
if err != nil {
return nil, errors.Wrap(err, "unable to parse "+f.FullPath)
}
p.Body = mdmd.body
p.FullPath = f.FullPath
p.ModifiedAt = f.Info.ModTime()
p.OutputFilename = fmt.Sprintf("%s-%s.pdf", config.Config().FilePrefix, p.Acronym)
policies = append(policies, p)
}
return policies, nil
}
type metadataMarkdown struct {
yaml string
body string
}
func loadMDMD(path string) metadataMarkdown {
bytes, err := ioutil.ReadFile(path)
if err != nil {
panic(err)
}
content := string(bytes)
components := strings.Split(content, "---")
if len(components) == 1 {
panic(fmt.Sprintf("Malformed metadata markdown in %s, must be of the form: YAML\\n---\\nmarkdown content", path))
}
yaml := components[0]
body := strings.Join(components[1:], "---")
return metadataMarkdown{yaml, body}
}

17
internal/model/model.go Normal file
View File

@ -0,0 +1,17 @@
package model
type Data struct {
Standards []*Standard
Narratives []*Narrative
Policies []*Policy
Procedures []*Procedure
Tickets []*Ticket
Audits []*Audit
}
type Revision struct {
Date string `yaml:"date"`
Comment string `yaml:"comment"`
}
type Satisfaction map[string][]string

View File

@ -0,0 +1,44 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func TestMarshal(t *testing.T) {
d := Data{
Tickets: []*Ticket{
&Ticket{
ID: "t1",
},
},
Audits: []*Audit{
&Audit{
ID: "a1",
},
},
Procedures: []*Procedure{
&Procedure{
Code: "pro1",
},
},
Policies: []*Policy{
&Policy{
Name: "pol1",
},
},
}
m, _ := json.Marshal(d)
encoded := string(m)
if !strings.Contains(encoded, "t1") ||
!strings.Contains(encoded, "a1") ||
!strings.Contains(encoded, "pro1") ||
!strings.Contains(encoded, "pol1") {
t.Error("identifier not found in marshalled string")
}
}

View File

@ -0,0 +1,15 @@
package model
import "time"
type Narrative struct {
Name string `yaml:"name"`
Acronym string `yaml:"acronym"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

102
internal/model/plugin.go Normal file
View File

@ -0,0 +1,102 @@
package model
import (
"fmt"
"sync"
"github.com/davecgh/go-spew/spew"
"github.com/strongdm/comply/internal/config"
)
var tsPluginsMu sync.Mutex
var tsPlugins = make(map[TicketSystem]TicketPlugin)
var tsConfigureOnce sync.Once
// TicketSystem is the type of ticket database.
type TicketSystem string
const (
// Jira from Atlassian.
Jira = TicketSystem("jira")
// GitHub from GitHub.
GitHub = TicketSystem("github")
// NoTickets indicates no ticketing system integration.
NoTickets = TicketSystem("none")
)
type TicketLinks struct {
ProcedureOpen string
ProcedureAll string
AuditOpen string
AuditAll string
}
// TicketPlugin models support for ticketing systems.
type TicketPlugin interface {
Get(ID string) (*Ticket, error)
FindOpen() ([]*Ticket, error)
FindByTag(name, value string) ([]*Ticket, error)
FindByTagName(name string) ([]*Ticket, error)
Create(ticket *Ticket, labels []string) error
Configure(map[string]interface{}) error
Prompts() map[string]string
Links() TicketLinks
LinkFor(ticket *Ticket) string
Configured() bool
}
// GetPlugin loads the ticketing database.
func GetPlugin(ts TicketSystem) TicketPlugin {
tsPluginsMu.Lock()
defer tsPluginsMu.Unlock()
tp, ok := tsPlugins[ts]
if !ok {
panic("Unknown ticket system: " + ts)
}
if config.Exists() {
tsConfigureOnce.Do(func() {
ticketsMap := config.Config().Tickets
hasTickets := true
cfg, ok := ticketsMap[string(ts)]
if !ok {
hasTickets = false
}
if hasTickets {
cfgTyped, ok := cfg.(map[interface{}]interface{})
if !ok {
spew.Dump(cfg)
panic(fmt.Sprintf("malformatted ticket configuration block `%s` in project YAML", string(ts)))
}
cfgStringed := make(map[string]interface{})
for k, v := range cfgTyped {
kS, ok := k.(string)
if !ok {
spew.Dump(cfgStringed)
panic(fmt.Sprintf("malformatted key in configuration block `%s` in project YAML", string(ts)))
}
cfgStringed[kS] = v
}
tp.Configure(cfgStringed)
}
})
}
return tp
}
// Register ticketing system plugin.
func Register(ts TicketSystem, plugin TicketPlugin) {
tsPluginsMu.Lock()
defer tsPluginsMu.Unlock()
_, ok := tsPlugins[ts]
if ok {
panic("Duplicate ticketing system registration: " + ts)
}
tsPlugins[ts] = plugin
}

15
internal/model/policy.go Normal file
View File

@ -0,0 +1,15 @@
package model
import "time"
type Policy struct {
Name string `yaml:"name"`
Acronym string `yaml:"acronym"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

View File

@ -0,0 +1,16 @@
package model
import "time"
type Procedure struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Cron string `yaml:"cron"`
Revisions []Revision `yaml:"majorRevisions"`
Satisfies Satisfaction `yaml:"satisfies"`
FullPath string
OutputFilename string
ModifiedAt time.Time
Body string
}

View File

@ -0,0 +1,49 @@
package model
type Control struct {
Family string `yaml:"family"`
Name string `yaml:"name"`
Description string `yaml:"description"`
}
type Standard struct {
Name string `yaml:"name"`
Controls map[string]Control `yaml:",inline"`
}
// ControlsSatisfied determines the unique controls currently satisfied by all Narratives, Policies, and Procedures
func ControlsSatisfied(data *Data) map[string][]string {
satisfied := make(map[string][]string)
appendSatisfaction := func(in map[string][]string, k string, v string) []string {
s, ok := in[k]
if !ok {
s = make([]string, 0)
}
s = append(s, v)
return s
}
for _, n := range data.Narratives {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
for _, n := range data.Policies {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
for _, n := range data.Procedures {
for _, controlKeys := range n.Satisfies {
for _, key := range controlKeys {
satisfied[key] = appendSatisfaction(satisfied, key, n.OutputFilename)
}
}
}
return satisfied
}

65
internal/model/ticket.go Normal file
View File

@ -0,0 +1,65 @@
package model
import (
"strings"
"time"
)
type TicketState string
const (
Open = TicketState("open")
Closed = TicketState("closed")
)
type Ticket struct {
ID string
Name string
State TicketState
Body string
Attributes map[string]interface{}
ClosedAt *time.Time
CreatedAt *time.Time
UpdatedAt *time.Time
}
func (t *Ticket) ProcedureID() string {
md := t.metadata()
if v, ok := md["Procedure-ID"]; ok {
return v
}
return ""
}
func (t *Ticket) metadata() map[string]string {
md := make(map[string]string)
lines := strings.Split(t.Body, "\n")
for _, line := range lines {
// TODO: transition to RFC822 parsing
if strings.Contains(line, ":") {
tokens := strings.Split(line, ":")
if len(tokens) != 2 {
continue
}
md[strings.TrimSpace(tokens[0])] = strings.TrimSpace(tokens[1])
}
}
return md
}
func (t *Ticket) SetBool(name string) {
t.Attributes[name] = true
}
func (t *Ticket) Bool(name string) bool {
bi, ok := t.Attributes[name]
if !ok {
return false
}
b, ok := bi.(bool)
if !ok {
return false
}
return b
}

4
internal/path/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package path provides convenient access to comply project path conventions.
*/
package path

55
internal/path/path.go Normal file
View File

@ -0,0 +1,55 @@
package path
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// File wraps an os.FileInfo as well as the absolute path to the underlying file.
type File struct {
FullPath string
Info os.FileInfo
}
// Standards lists all standard files.
func Standards() ([]File, error) {
return filesFor("standards", "yml")
}
// Narratives lists all narrative files.
func Narratives() ([]File, error) {
return filesFor("narratives", "md")
}
// Policies lists all policy files.
func Policies() ([]File, error) {
return filesFor("policies", "md")
}
// Procedures lists all procedure files.
func Procedures() ([]File, error) {
return filesFor("procedures", "md")
}
func filesFor(name, extension string) ([]File, error) {
var filtered []File
files, err := ioutil.ReadDir(filepath.Join(".", name))
if err != nil {
return nil, errors.Wrap(err, "unable to load files for: "+name)
}
for _, f := range files {
if !strings.HasSuffix(f.Name(), "."+extension) || strings.HasPrefix(strings.ToUpper(f.Name()), "README") {
continue
}
abs, err := filepath.Abs(filepath.Join(".", name, f.Name()))
if err != nil {
return nil, errors.Wrap(err, "unable to load file: "+f.Name())
}
filtered = append(filtered, File{abs, f})
}
return filtered, nil
}

View File

@ -0,0 +1,187 @@
package github
import (
"context"
"fmt"
"strconv"
"sync"
"github.com/google/go-github/github"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/model"
"golang.org/x/oauth2"
)
const (
cfgToken = "token"
cfgUsername = "username"
cfgRepo = "repo"
)
var prompts = map[string]string{
cfgToken: "GitHub Token",
cfgUsername: "GitHub Username",
cfgRepo: "GitHub Repository",
}
// Prompts are human-readable configuration element names
func (g *githubPlugin) Prompts() map[string]string {
return prompts
}
// Register causes the Github plugin to register itself
func Register() {
model.Register(model.GitHub, &githubPlugin{})
}
type githubPlugin struct {
token string
username string
reponame string
clientMu sync.Mutex
client *github.Client
}
func (g *githubPlugin) api() *github.Client {
g.clientMu.Lock()
defer g.clientMu.Unlock()
if g.client == nil {
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: g.token},
)
// get go-github client
gh := github.NewClient(oauth2.NewClient(context.Background(), ts))
g.client = gh
}
return g.client
}
func (g *githubPlugin) Get(ID string) (*model.Ticket, error) {
return nil, nil
}
func (g *githubPlugin) Configured() bool {
return g.username != "" && g.reponame != "" && g.token != ""
}
func (g *githubPlugin) Links() model.TicketLinks {
links := model.TicketLinks{}
links.AuditAll = fmt.Sprintf("https://github.com/%s/%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aaudit", g.username, g.reponame)
links.AuditOpen = fmt.Sprintf("https://github.com/%s/%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aaudit", g.username, g.reponame)
links.ProcedureAll = fmt.Sprintf("https://github.com/%s/%s/issues?q=is%3Aissue+label%3Acomply+label%3Aprocedure", g.username, g.reponame)
links.ProcedureOpen = fmt.Sprintf("https://github.com/%s/%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aprocedure", g.username, g.reponame)
return links
}
func (g *githubPlugin) Configure(cfg map[string]interface{}) error {
var err error
if g.token, err = getCfg(cfg, cfgToken); err != nil {
return err
}
if g.username, err = getCfg(cfg, cfgUsername); err != nil {
return err
}
if g.reponame, err = getCfg(cfg, cfgRepo); err != nil {
return err
}
return nil
}
func getCfg(cfg map[string]interface{}, k string) (string, error) {
v, ok := cfg[k]
if !ok {
return "", errors.New("Missing key: " + k)
}
vS, ok := v.(string)
if !ok {
return "", errors.New("Malformatted key: " + k)
}
return vS, nil
}
func (g *githubPlugin) FindOpen() ([]*model.Ticket, error) {
issues, _, err := g.api().Issues.ListByRepo(context.Background(), g.username, g.reponame, &github.IssueListByRepoOptions{
State: "open",
})
if err != nil {
return nil, errors.Wrap(err, "error during FindOpen")
}
return toTickets(issues), nil
}
func (g *githubPlugin) FindByTag(name, value string) ([]*model.Ticket, error) {
panic("not implemented")
}
func (g *githubPlugin) FindByTagName(name string) ([]*model.Ticket, error) {
issues, _, err := g.api().Issues.ListByRepo(context.Background(), g.username, g.reponame, &github.IssueListByRepoOptions{
State: "all",
Labels: []string{name},
})
if err != nil {
return nil, errors.Wrap(err, "error during FindOpen")
}
return toTickets(issues), nil
}
func (g *githubPlugin) LinkFor(t *model.Ticket) string {
return fmt.Sprintf("https://github.com/strongdm/comply/issues/%s", t.ID)
}
func (g *githubPlugin) Create(ticket *model.Ticket, labels []string) error {
_, _, err := g.api().Issues.Create(context.Background(), g.username, g.reponame, &github.IssueRequest{
Title: &ticket.Name,
Body: &ticket.Body,
Labels: &labels,
})
return err
}
func toTickets(issues []*github.Issue) []*model.Ticket {
var tickets []*model.Ticket
for _, i := range issues {
tickets = append(tickets, toTicket(i))
}
return tickets
}
func toTicket(i *github.Issue) *model.Ticket {
t := &model.Ticket{Attributes: make(map[string]interface{})}
t.ID = strconv.Itoa(*i.Number)
t.Name = ss(i.Title)
t.Body = ss(i.Body)
t.CreatedAt = i.CreatedAt
t.State = toState(ss(i.State))
for _, l := range i.Labels {
if l.Name != nil {
t.SetBool(*l.Name)
}
}
return t
}
func toState(state string) model.TicketState {
switch state {
case "closed":
return model.Closed
}
return model.Open
}
func ss(s *string) string {
if s == nil {
return ""
}
return *s
}

View File

@ -0,0 +1,152 @@
package render
import (
"fmt"
"sort"
"time"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
type project struct {
OrganizationName string
Name string
}
type stats struct {
ControlsTotal int
ControlsSatisfied int
ProcedureTotal int
ProcedureOpen int
ProcedureOldestDays int
AuditOpen int
AuditClosed int
AuditTotal int
}
type renderData struct {
// duplicates Project.OrganizationName
Name string
Project *project
Stats *stats
Narratives []*model.Narrative
Policies []*model.Policy
Procedures []*model.Procedure
Standards []*model.Standard
Tickets []*model.Ticket
Controls []*control
Links *model.TicketLinks
}
type control struct {
Standard string
ControlKey string
Name string
Description string
Satisfied bool
SatisfiedBy []string
}
func load() (*model.Data, *renderData, error) {
modelData, err := model.ReadData()
if err != nil {
return nil, nil, err
}
cfg := config.Config()
project := &project{
OrganizationName: cfg.Name,
Name: fmt.Sprintf("%s Compliance Program", cfg.Name),
}
satisfied := model.ControlsSatisfied(modelData)
controls := make([]*control, 0)
for _, standard := range modelData.Standards {
for key, c := range standard.Controls {
satisfactions, ok := satisfied[key]
satisfied := ok && len(satisfactions) > 0
controls = append(controls, &control{
Standard: standard.Name,
ControlKey: key,
Name: c.Name,
Description: c.Description,
Satisfied: satisfied,
SatisfiedBy: satisfactions,
})
}
}
sort.Slice(controls, func(i, j int) bool {
return controls[i].ControlKey < controls[j].ControlKey
})
rd := &renderData{}
rd.Narratives = modelData.Narratives
rd.Policies = modelData.Policies
rd.Procedures = modelData.Procedures
rd.Standards = modelData.Standards
rd.Tickets = modelData.Tickets
rd.Links = &model.TicketLinks{}
rd.Project = project
rd.Name = project.OrganizationName
rd.Controls = controls
// TODO: unhardcode plugin
tp := model.GetPlugin(model.GitHub)
if tp.Configured() {
links := tp.Links()
rd.Links = &links
}
return modelData, rd, nil
}
func loadWithStats() (*model.Data, *renderData, error) {
modelData, renderData, err := load()
if err != nil {
return nil, nil, err
}
addStats(modelData, renderData)
return modelData, renderData, nil
}
func addStats(modelData *model.Data, renderData *renderData) {
stats := &stats{}
satisfied := model.ControlsSatisfied(modelData)
for _, std := range renderData.Standards {
stats.ControlsTotal += len(std.Controls)
for controlKey := range std.Controls {
if _, ok := satisfied[controlKey]; ok {
stats.ControlsSatisfied++
}
}
}
for _, t := range renderData.Tickets {
if t.Bool("audit") {
stats.AuditTotal++
}
if t.State == model.Open {
if t.Bool("procedure") {
stats.ProcedureOpen++
if t.CreatedAt != nil {
age := int(time.Since(*t.CreatedAt).Hours() / float64(24))
if stats.ProcedureOldestDays < age {
stats.ProcedureOldestDays = age
}
}
}
if t.Bool("audit") {
stats.AuditOpen++
}
}
}
renderData.Stats = stats
}

4
internal/render/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package render defines markdown preprocessors, HTML and PDF generation.
*/
package render

86
internal/render/html.go Normal file
View File

@ -0,0 +1,86 @@
package render
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/yosssi/ace"
)
const websocketReloader = `<script>
(function(){
var ws = new WebSocket("ws://localhost:5122/ws")
var connected = false
ws.onopen = function(e) {
connected = true
}
ws.onclose = function(e) {
// reload!
if (connected) {
window.location=window.location
}
}
})()
</script>`
func html(output string, live bool, errCh chan error, wg *sync.WaitGroup) {
for {
files, err := ioutil.ReadDir(filepath.Join(".", "templates"))
if err != nil {
errCh <- errors.Wrap(err, "unable to open template directory")
return
}
_, data, err := loadWithStats()
if err != nil {
errCh <- errors.Wrap(err, "unable to load data")
return
}
for _, fileInfo := range files {
if !strings.HasSuffix(fileInfo.Name(), ".ace") {
continue
}
basename := strings.Replace(fileInfo.Name(), ".ace", "", -1)
outputFilename := filepath.Join(output, fmt.Sprintf("%s.html", basename))
w, err := os.Create(outputFilename)
if err != nil {
errCh <- errors.Wrap(err, "unable to create HTML file")
return
}
if live {
fmt.Printf("%s -> %s\n", filepath.Join("templates", fileInfo.Name()), outputFilename)
}
tpl, err := ace.Load("", filepath.Join("templates", basename), aceOpts)
if err != nil {
w.Write([]byte("<htmL><body>template error</body></html>"))
fmt.Println(err)
}
err = tpl.Execute(w, data)
if err != nil {
w.Write([]byte("<htmL><body>template error</body></html>"))
fmt.Println(err)
}
if live {
w.Write([]byte(websocketReloader))
}
w.Close()
}
if !live {
wg.Done()
return
}
<-subscribe()
}
}

View File

@ -0,0 +1,185 @@
package render
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
// TODO: refactor and eliminate duplication among narrative, policy renderers
func renderNarrativeToDisk(wg *sync.WaitGroup, errOutputCh chan error, data *renderData, narrative *model.Narrative, live bool) {
// only files that have been touched
if !isNewer(narrative.FullPath, narrative.ModifiedAt) {
return
}
recordModified(narrative.FullPath, narrative.ModifiedAt)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to read Docker environment")
return
}
pwd, err := os.Getwd()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to get workding directory")
return
}
hc := &container.HostConfig{
Binds: []string{pwd + ":/source"},
}
wg.Add(1)
go func(p *model.Narrative) {
defer wg.Done()
if live {
rel, err := filepath.Rel(config.ProjectRoot(), p.FullPath)
if err != nil {
rel = p.FullPath
}
fmt.Printf("%s -> %s\n", rel, filepath.Join("output", p.OutputFilename))
}
outputFilename := p.OutputFilename
// save preprocessed markdown
err = preprocessNarrative(data, p, filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to preprocess")
return
}
cmd := []string{"--smart", "--toc", "-N", "--template=/source/templates/default.latex", "-o",
fmt.Sprintf("/source/output/%s", outputFilename),
fmt.Sprintf("/source/output/%s.md", outputFilename)}
resp, err := cli.ContainerCreate(ctx, &container.Config{
Image: "strongdm/pandoc",
Cmd: cmd},
hc, nil, "")
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to create Docker container")
return
}
defer func() {
timeout := 2 * time.Second
cli.ContainerStop(ctx, resp.ID, &timeout)
err := cli.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to remove container")
return
}
}()
if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
errOutputCh <- errors.Wrap(err, "unable to start Docker container")
return
}
_, err = cli.ContainerWait(ctx, resp.ID)
if err != nil {
errOutputCh <- errors.Wrap(err, "error awaiting Docker container")
return
}
_, err = cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "error reading Docker container logs")
return
}
// remove preprocessed markdown
err = os.Remove(filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- err
return
}
}(narrative)
}
func preprocessNarrative(data *renderData, pol *model.Narrative, fullPath string) error {
cfg := config.Config()
var w bytes.Buffer
bodyTemplate, err := template.New("body").Parse(pol.Body)
if err != nil {
w.WriteString(fmt.Sprintf("# Error processing template:\n\n%s\n", err.Error()))
} else {
bodyTemplate.Execute(&w, data)
}
body := w.String()
revisionTable := ""
satisfiesTable := ""
// ||Date|Comment|
// |---+------|
// | 4 Jan 2018 | Initial Version |
// Table: Document history
if len(pol.Satisfies) > 0 {
rows := ""
for standard, keys := range pol.Satisfies {
rows += fmt.Sprintf("| %s | %s |\n", standard, strings.Join(keys, ", "))
}
satisfiesTable = fmt.Sprintf("|Standard|Controls Satisfied|\n|-------+--------------------------------------------|\n%s\nTable: Control satisfaction\n", rows)
}
if len(pol.Revisions) > 0 {
rows := ""
for _, rev := range pol.Revisions {
rows += fmt.Sprintf("| %s | %s |\n", rev.Date, rev.Comment)
}
revisionTable = fmt.Sprintf("|Date|Comment|\n|---+--------------------------------------------|\n%s\nTable: Document history\n", rows)
}
doc := fmt.Sprintf(`%% %s
%% %s
%% %s
---
header-includes: yes
head-content: "%s"
foot-content: "%s confidential %d"
---
%s
%s
\newpage
%s`,
pol.Name,
cfg.Name,
fmt.Sprintf("%s %d", pol.ModifiedAt.Month().String(), pol.ModifiedAt.Year()),
pol.Name,
cfg.Name,
time.Now().Year(),
satisfiesTable,
revisionTable,
body,
)
err = ioutil.WriteFile(fullPath, []byte(doc), os.FileMode(0644))
if err != nil {
return errors.Wrap(err, "unable to write preprocessed narrative to disk")
}
return nil
}

49
internal/render/pdf.go Normal file
View File

@ -0,0 +1,49 @@
package render
import (
"sync"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/model"
)
func pdf(output string, live bool, errCh chan error, wg *sync.WaitGroup) {
var pdfWG sync.WaitGroup
errOutputCh := make(chan error)
for {
_, data, err := loadWithStats()
if err != nil {
errCh <- errors.Wrap(err, "unable to load data")
return
}
policies, err := model.ReadPolicies()
if err != nil {
errCh <- errors.Wrap(err, "unable to read policies")
return
}
for _, policy := range policies {
renderPolicyToDisk(&pdfWG, errOutputCh, data, policy, live)
}
narratives, err := model.ReadNarratives()
if err != nil {
errCh <- errors.Wrap(err, "unable to read narratives")
return
}
for _, narrative := range narratives {
renderNarrativeToDisk(&pdfWG, errOutputCh, data, narrative, live)
}
pdfWG.Wait()
if !live {
wg.Done()
return
}
<-subscribe()
}
}

183
internal/render/policy.go Normal file
View File

@ -0,0 +1,183 @@
package render
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/strongdm/comply/internal/config"
"github.com/strongdm/comply/internal/model"
)
// TODO: refactor and eliminate duplication among narrative, policy renderers
func renderPolicyToDisk(wg *sync.WaitGroup, errOutputCh chan error, data *renderData, policy *model.Policy, live bool) {
// only files that have been touched
if !isNewer(policy.FullPath, policy.ModifiedAt) {
return
}
recordModified(policy.FullPath, policy.ModifiedAt)
ctx := context.Background()
cli, err := client.NewEnvClient()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to read Docker environment")
return
}
pwd, err := os.Getwd()
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to get workding directory")
return
}
hc := &container.HostConfig{
Binds: []string{pwd + ":/source"},
}
wg.Add(1)
go func(p *model.Policy) {
defer wg.Done()
if live {
rel, err := filepath.Rel(config.ProjectRoot(), p.FullPath)
if err != nil {
rel = p.FullPath
}
fmt.Printf("%s -> %s\n", rel, filepath.Join("output", p.OutputFilename))
}
outputFilename := p.OutputFilename
// save preprocessed markdown
err = preprocessPolicy(data, p, filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to preprocess")
return
}
resp, err := cli.ContainerCreate(ctx, &container.Config{
Image: "strongdm/pandoc",
Cmd: []string{"--smart", "--toc", "-N", "--template=/source/templates/default.latex", "-o",
fmt.Sprintf("/source/output/%s", outputFilename),
fmt.Sprintf("/source/output/%s.md", outputFilename),
},
}, hc, nil, "")
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to create Docker container")
return
}
defer func() {
timeout := 2 * time.Second
cli.ContainerStop(ctx, resp.ID, &timeout)
err := cli.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "unable to remove container")
return
}
}()
if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
errOutputCh <- errors.Wrap(err, "unable to start Docker container")
return
}
_, err = cli.ContainerWait(ctx, resp.ID)
if err != nil {
errOutputCh <- errors.Wrap(err, "error awaiting Docker container")
return
}
_, err = cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
errOutputCh <- errors.Wrap(err, "error reading Docker container logs")
return
}
// remove preprocessed markdown
err = os.Remove(filepath.Join(".", "output", outputFilename+".md"))
if err != nil {
errOutputCh <- err
return
}
}(policy)
}
func preprocessPolicy(data *renderData, pol *model.Policy, fullPath string) error {
cfg := config.Config()
var w bytes.Buffer
bodyTemplate, err := template.New("body").Parse(pol.Body)
if err != nil {
w.WriteString(fmt.Sprintf("# Error processing template:\n\n%s\n", err.Error()))
} else {
bodyTemplate.Execute(&w, data)
}
body := w.String()
revisionTable := ""
satisfiesTable := ""
// ||Date|Comment|
// |---+------|
// | 4 Jan 2018 | Initial Version |
// Table: Document history
if len(pol.Satisfies) > 0 {
rows := ""
for standard, keys := range pol.Satisfies {
rows += fmt.Sprintf("| %s | %s |\n", standard, strings.Join(keys, ", "))
}
satisfiesTable = fmt.Sprintf("|Standard|Controls Satisfied|\n|-------+--------------------------------------------|\n%s\nTable: Control satisfaction\n", rows)
}
if len(pol.Revisions) > 0 {
rows := ""
for _, rev := range pol.Revisions {
rows += fmt.Sprintf("| %s | %s |\n", rev.Date, rev.Comment)
}
revisionTable = fmt.Sprintf("|Date|Comment|\n|---+--------------------------------------------|\n%s\nTable: Document history\n", rows)
}
doc := fmt.Sprintf(`%% %s
%% %s
%% %s
---
header-includes: yes
head-content: "%s"
foot-content: "%s confidential %d"
---
%s
%s
\newpage
%s`,
pol.Name,
cfg.Name,
fmt.Sprintf("%s %d", pol.ModifiedAt.Month().String(), pol.ModifiedAt.Year()),
pol.Name,
cfg.Name,
time.Now().Year(),
satisfiesTable,
revisionTable,
body,
)
err = ioutil.WriteFile(fullPath, []byte(doc), os.FileMode(0644))
if err != nil {
return errors.Wrap(err, "unable to write preprocessed policy to disk")
}
return nil
}

118
internal/render/site.go Normal file
View File

@ -0,0 +1,118 @@
package render
import (
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"github.com/skratchdot/open-golang/open"
"github.com/yosssi/ace"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var aceOpts = &ace.Options{
DynamicReload: true,
Indent: " ",
}
var watchChMu sync.Mutex
var watchCh chan struct{}
func subscribe() chan struct{} {
watchChMu.Lock()
defer watchChMu.Unlock()
if watchCh == nil {
watchCh = make(chan struct{})
}
return watchCh
}
func broadcast() {
watchChMu.Lock()
defer watchChMu.Unlock()
close(watchCh)
watchCh = nil
}
var lastModifiedMu sync.Mutex
var lastModified = make(map[string]time.Time)
func recordModified(path string, t time.Time) {
lastModifiedMu.Lock()
defer lastModifiedMu.Unlock()
previous, ok := lastModified[path]
if !ok || t.After(previous) {
lastModified[path] = t
}
}
func isNewer(path string, t time.Time) bool {
lastModifiedMu.Lock()
defer lastModifiedMu.Unlock()
previous, ok := lastModified[path]
if !ok {
return true
}
// is tested after previous? Then isNewer is true.
return t.After(previous)
}
// Build generates all PDF and HTML output to the target directory with optional live reload.
func Build(output string, live bool) error {
err := os.RemoveAll(output)
if err != nil {
errors.Wrap(err, "unable to remove files from output directory")
}
err = os.MkdirAll(output, os.FileMode(0755))
if err != nil {
errors.Wrap(err, "unable to create output directory")
}
var wg sync.WaitGroup
errCh := make(chan error, 0)
wgCh := make(chan struct{})
if live {
watch(errCh)
}
// PDF
wg.Add(1)
go pdf(output, live, errCh, &wg)
// HTML
wg.Add(1)
go html(output, live, errCh, &wg)
// WG monitor
go func() {
wg.Wait()
close(wgCh)
}()
if live {
open.Run("output/index.html")
}
select {
case <-wgCh:
// success
case err := <-errCh:
return errors.Wrap(err, "error during build")
}
return nil
}

50
internal/render/watch.go Normal file
View File

@ -0,0 +1,50 @@
package render
import (
"net/http"
"time"
"github.com/gohugoio/hugo/watcher"
)
func watch(errCh chan error) {
b, err := watcher.New(300 * time.Millisecond)
if err != nil {
errCh <- err
return
}
b.Add("./templates/")
b.Add("./policies/")
b.Add("./procedures/")
b.Add("./.comply/")
b.Add("./.comply/cache")
b.Add("./.comply/cache/tickets")
go func() {
for {
select {
case e := <-b.Errors:
errCh <- e
case <-b.Events:
broadcast()
}
}
}()
serveWs := func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
errCh <- err
return
}
<-subscribe()
time.Sleep(500 * time.Millisecond)
ws.Close()
}
http.HandleFunc("/ws", serveWs)
go http.ListenAndServe("127.0.0.1:5122", nil)
return
}

52
internal/theme/theme.go Normal file
View File

@ -0,0 +1,52 @@
package theme
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/template"
)
// SaveTo persists a compliance theme to a destination directory with optional
// template value replacements.
func SaveTo(themeName string, replace map[string]string, saveDir string) error {
for _, name := range AssetNames() {
prefix := themeName + "/"
if strings.HasPrefix(name, prefix) {
outputName := strings.TrimPrefix(name, prefix)
assetDir, assetFilename := filepath.Split(outputName)
err := os.MkdirAll(filepath.Join(saveDir, assetDir), os.FileMode(0755))
if err != nil {
return err
}
// special case for README.md and TODO.md: all other templates
// are passed copied verbatim.
if name == filepath.Join(themeName, "README.md") || name == filepath.Join(themeName, "TODO.md") {
rootMdFile := string(MustAsset(name))
var w bytes.Buffer
rootMdFileTemplate, err := template.New("rootMdFile").Parse(rootMdFile)
if err != nil {
w.WriteString(fmt.Sprintf("# Error processing template:\n\n%s\n", err.Error()))
} else {
rootMdFileTemplate.Execute(&w, replace)
}
body := w.String()
err = ioutil.WriteFile(filepath.Join(saveDir, assetDir, assetFilename), []byte(body), os.FileMode(0644))
} else {
err = ioutil.WriteFile(filepath.Join(saveDir, assetDir, assetFilename), MustAsset(name), os.FileMode(0644))
}
if err != nil {
return err
}
}
}
// TODO
return nil
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,107 @@
package ticket
import (
"fmt"
"sort"
"time"
"github.com/robfig/cron"
"github.com/strongdm/comply/internal/model"
)
func byProcedureByTime(tickets []*model.Ticket) map[string][]*model.Ticket {
result := make(map[string][]*model.Ticket)
for _, t := range tickets {
procedureID := t.ProcedureID()
if procedureID == "" {
// missing procedure metadata; skip
continue
}
list, ok := result[procedureID]
if !ok {
list = make([]*model.Ticket, 0)
}
list = append(list, t)
sort.Slice(list, func(i, j int) bool {
if list[i].CreatedAt == nil || list[j].CreatedAt == nil {
return false
}
return list[i].CreatedAt.Before(*list[j].CreatedAt)
})
result[procedureID] = list
}
return result
}
func TriggerScheduled() error {
rawTickets, err := model.ReadTickets()
if err != nil {
return err
}
tickets := byProcedureByTime(rawTickets)
procedures, err := model.ReadProcedures()
if err != nil {
return err
}
for _, procedure := range procedures {
if procedure.Cron == "" {
continue
}
procedureID := procedure.ID
schedule, err := cron.Parse(procedure.Cron)
if err != nil {
continue
}
ticketsForProc, ok := tickets[procedureID]
if ok {
// find most recent one
mostRecent := ticketsForProc[len(ticketsForProc)-1]
if mostRecent.CreatedAt == nil {
continue
}
// would another have triggered since?
nextTrigger := schedule.Next(*mostRecent.CreatedAt).UTC()
if nextTrigger.After(time.Now().UTC()) {
// in the future, nothing to do
continue
}
trigger(procedure)
} else {
// don't go back further than 13 months
tooOld := time.Now().Add(-1 * time.Hour * 24 * (365 + 30))
// search back one day until triggers
triggeredAt := time.Now().Add(-24 * time.Hour).UTC()
SEARCH:
for {
if triggeredAt.Before(tooOld) {
break SEARCH
}
candidate := schedule.Next(triggeredAt)
// in the future? not far eonugh back yet.
if candidate.After(time.Now().UTC()) {
triggeredAt = triggeredAt.Add(-24 * time.Hour)
continue
}
// is in the past? then trigger.
trigger(procedure)
break SEARCH
}
}
}
return nil
}
func trigger(procedure *model.Procedure) {
// TODO: don't hardcode GH
tp := model.GetPlugin(model.GitHub)
tp.Create(&model.Ticket{
Name: procedure.Name,
Body: fmt.Sprintf("%s\n\n\n---\nProcedure-ID: %s", procedure.Body, procedure.ID),
}, []string{"comply", "comply-procedure"})
}

BIN
pdf-example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
sc-1.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
sc-2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
sc-3.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

BIN
sc-4.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Some files were not shown because too many files have changed in this diff Show More