pipedawg 0.2.2 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1df968015369cc666fc521d546b3194e82f04ec28b9a2473dbc2452b5c37780
4
- data.tar.gz: 5afe211e7fb69ac10808af059e02dc5706c882ad86d2309901d2f943e0ec6a6d
3
+ metadata.gz: 53733705295ce1c9b2ae80fc1c8368e7e08e7d7a0e1fcf3727fe972dd5714fb1
4
+ data.tar.gz: 10e86751592488fbb9972605892d79af662e60e3a3da070292cf4933f31b7981
5
5
  SHA512:
6
- metadata.gz: 7348909008c56e40d693f388da3b6eb8210c380a357c87c5e1cf906932a9c66246af77a9328f0746d70b2213943c95f73256d835b5163ca17348a9068998a5ac
7
- data.tar.gz: 9f3b4d44c1e2e5e2b955ecd135ccdcfd100d15fbcc137f40dca0af2797a0aa3637444e2e75ec910ac428a953147d883ecb24414c6e10bba17dfb0d40f3881b70
6
+ metadata.gz: 11584d541a5d63f54761dee9ca878e3c79ba168ee1e5e93ec9ddccd559d869672a900a6aa25d966df4ef807a56ac0c75385644baf3bce5498ac7a68a29fd8a9a
7
+ data.tar.gz: 97f20d550f1cbe4bf298a2f6884f02754cd385e317d777887d11a8748d7436d8bb6db035944c6f59d2593b4e3a615db76f28c678bb392cf16ef3e6c6f92770a9
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # helm_copy_job class
5
+ class HelmCopyJob < Job
6
+ attr_accessor :helm_opts
7
+
8
+ def initialize(name = 'build', opts = {}, helm_opts = {})
9
+ @helm_opts = {
10
+ chart: name, debug: true,
11
+ destinations: [{ user: nil, password: nil, url: nil }],
12
+ helm: 'helm',
13
+ image: { entrypoint: [''], name: 'alpine/helm' },
14
+ password: nil, url: nil, user: nil, version: nil
15
+ }.merge(helm_opts)
16
+ super name, opts
17
+ update
18
+ end
19
+
20
+ def update # rubocop:disable Metrics/AbcSize
21
+ opts[:image] = helm_opts[:image] if helm_opts[:image]
22
+ opts[:script] = debug + pull + (helm_opts[:destinations].map { |d| push(d) }).flatten(1)
23
+ end
24
+
25
+ private
26
+
27
+ def debug
28
+ if helm_opts[:debug]
29
+ Pipedawg::Util.echo_proxy_vars
30
+ else
31
+ []
32
+ end
33
+ end
34
+
35
+ def pull
36
+ case helm_opts[:url]
37
+ when nil
38
+ []
39
+ when %r{^oci://}
40
+ pull_oci
41
+ else
42
+ pull_classic
43
+ end
44
+ end
45
+
46
+ def push(destination)
47
+ case destination[:url]
48
+ when nil
49
+ []
50
+ when %r{^oci://}
51
+ push_oci(destination)
52
+ else
53
+ push_classic(destination)
54
+ end
55
+ end
56
+
57
+ def pull_oci # rubocop:disable Metrics/AbcSize
58
+ script = []
59
+ if helm_opts[:url] && helm_opts[:chart] && helm_opts[:version]
60
+ script = ['export HELM_EXPERIMENTAL_OCI=1']
61
+ script << login_oci(helm_opts) if helm_opts[:user] && helm_opts[:password]
62
+ script << "\"#{helm_opts[:helm]}\" pull \"#{helm_opts[:url]}/#{helm_opts[:chart]}\" --version \"#{helm_opts[:version]}\"" # rubocop:disable Layout/LineLength
63
+ end
64
+ script
65
+ end
66
+
67
+ def push_oci(destination) # rubocop:disable Metrics/AbcSize
68
+ script = []
69
+ if destination[:url] && helm_opts[:chart] && helm_opts[:version]
70
+ script = ['export HELM_EXPERIMENTAL_OCI=1']
71
+ script << login_oci(destination) if destination[:user] && destination[:password]
72
+ script << "\"#{helm_opts[:helm]}\" push \"#{helm_opts[:chart]}-#{helm_opts[:version]}.tgz\" \"#{destination[:url]}\"" # rubocop:disable Layout/LineLength
73
+ end
74
+ script
75
+ end
76
+
77
+ def login_oci(login_opts)
78
+ require 'uri'
79
+ "echo \"#{login_opts[:password]}\" | \"#{helm_opts[:helm]}\" registry login --username \"#{login_opts[:user]}\" --password-stdin \"#{URI(login_opts[:url]).host}\"" # rubocop:disable Layout/LineLength
80
+ end
81
+
82
+ def pull_classic # rubocop:disable Metrics/AbcSize
83
+ script = []
84
+ if helm_opts[:url] && helm_opts[:chart] && helm_opts[:version]
85
+ suffix = login_classic(helm_opts)
86
+ script << "\"#{helm_opts[:helm]}\" repo add source \"#{helm_opts[:url]}\"#{suffix}"
87
+ script << "\"#{helm_opts[:helm]}\" repo update"
88
+ script << "\"#{helm_opts[:helm]}\" pull \"source/#{helm_opts[:chart]}\" --version \"#{helm_opts[:version]}\""
89
+ end
90
+ script
91
+ end
92
+
93
+ def push_classic(destination)
94
+ script = []
95
+ if destination[:url] && helm_opts[:chart] && helm_opts[:version]
96
+ script << plugin_classic
97
+ suffix = login_classic(destination)
98
+ script << "\"#{helm_opts[:helm]}\" cm-push \"#{helm_opts[:chart]}-#{helm_opts[:version]}.tgz\" \"#{destination[:url]}\"#{suffix}" # rubocop:disable Layout/LineLength
99
+ end
100
+ script
101
+ end
102
+
103
+ def login_classic(login_opts)
104
+ if login_opts[:user] && login_opts[:password]
105
+ " --username \"#{login_opts[:user]}\" --password \"#{login_opts[:password]}\""
106
+ else
107
+ ''
108
+ end
109
+ end
110
+
111
+ def plugin_classic
112
+ "\"#{helm_opts[:helm]}\" plugin list | grep -q cm-push || \"#{helm_opts[:helm]}\" plugin install https://github.com/chartmuseum/helm-push"
113
+ end
114
+ end
115
+ end
@@ -9,82 +9,100 @@ module Pipedawg
9
9
  @kaniko_opts = {
10
10
  build_args: {},
11
11
  config: {
12
- '$CI_REGISTRY': {
13
- username: '$CI_REGISTRY_USER',
14
- password: '$CI_REGISTRY_PASSWORD'
15
- }
12
+ '$CI_REGISTRY': { username: '$CI_REGISTRY_USER', password: '$CI_REGISTRY_PASSWORD' }
16
13
  },
17
- config_file: '/kaniko/.docker/config.json',
18
- context: '${CI_PROJECT_DIR}',
19
- destinations: [],
20
- dockerfile: 'Dockerfile',
21
- executor: '/kaniko/executor',
22
- external_files: {},
23
- flags: [],
24
- ignore_paths: [],
25
- insecure_registries: [],
26
- kaniko_image: {
27
- entrypoint: [''],
28
- name: 'gcr.io/kaniko-project/executor:debug'
29
- },
30
- options: {},
31
- registry_certificates: {},
32
- registry_mirrors: [],
33
- skip_tls_verify_registry: [],
34
- trusted_ca_cert_source_files: [],
35
- trusted_ca_cert_target_file: '/kaniko/ssl/certs/ca-certificates.crt'
14
+ config_file: '/kaniko/.docker/config.json', context: '${CI_PROJECT_DIR}', debug: true,
15
+ destinations: [], dockerfile: 'Dockerfile', executor: '/kaniko/executor', external_files: {},
16
+ flags: [], ignore_paths: [], insecure_registries: [],
17
+ image: { entrypoint: [''], name: 'gcr.io/kaniko-project/executor:debug' }, options: {},
18
+ registry_certificates: {}, registry_mirrors: [], skip_tls_verify_registry: [],
19
+ trusted_ca_cert_source_files: [], trusted_ca_cert_target_file: '/kaniko/ssl/certs/ca-certificates.crt'
36
20
  }.merge(kaniko_opts)
37
21
  super name, opts
38
22
  update
39
23
  end
40
24
 
41
- def update # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
25
+ def update # rubocop:disable Metrics/AbcSize
42
26
  require 'json'
43
- opts[:image] = kaniko_opts[:image]
44
- script = ["echo #{kaniko_opts[:config].to_json.inspect} > \"#{kaniko_opts[:config_file]}\""]
45
- cert_copies = Array(kaniko_opts[:trusted_ca_cert_source_files]).map do |cert|
27
+ opts[:image] = kaniko_opts[:image] if kaniko_opts[:image]
28
+ opts[:script] = debug + config + cert_copies + file_copies + Array(kaniko_cmd)
29
+ end
30
+
31
+ private
32
+
33
+ def debug
34
+ if kaniko_opts[:debug]
35
+ Pipedawg::Util.echo_proxy_vars
36
+ else
37
+ []
38
+ end
39
+ end
40
+
41
+ def config
42
+ ["echo #{kaniko_opts[:config].to_json.inspect} > \"#{kaniko_opts[:config_file]}\""]
43
+ end
44
+
45
+ def cert_copies
46
+ Array(kaniko_opts[:trusted_ca_cert_source_files]).map do |cert|
46
47
  "cat \"#{cert}\" >> \"#{kaniko_opts[:trusted_ca_cert_target_file]}\""
47
48
  end
48
- script.concat cert_copies
49
- file_copies = kaniko_opts[:external_files].map do |source, dest|
49
+ end
50
+
51
+ def file_copies
52
+ kaniko_opts[:external_files].map do |source, dest|
50
53
  "cp \"#{source}\" \"#{kaniko_opts[:context]}/#{dest}\""
51
54
  end
52
- script.concat file_copies
55
+ end
56
+
57
+ def kaniko_cmd # rubocop:disable Metrics/AbcSize
58
+ ["\"#{kaniko_opts[:executor]}\" --context \"#{kaniko_opts[:context]}\"",
59
+ "--dockerfile \"#{kaniko_opts[:dockerfile]}\"", flags, options, build_args,
60
+ ignore_paths, insecure_registries, registry_certificates, registry_mirrors,
61
+ destinations, skip_tls_verify_registries].reject(&:empty?).join(' ')
62
+ end
63
+
64
+ def flags
53
65
  flags = kaniko_opts[:flags].clone
54
66
  flags << 'no-push' if kaniko_opts[:destinations].empty?
55
- flags_cli = flags.uniq.map { |f| "--#{f}" }.join(' ')
56
- options_cli = kaniko_opts[:options].map { |k, v| "--#{k}=\"#{v}\"" }.join(' ')
57
- build_args_cli = kaniko_opts[:build_args].map { |k, v| "--build-arg #{k}=\"#{v}\"" }.join(' ')
58
- ignore_paths_cli = Array(kaniko_opts[:ignore_paths]).map { |p| "--ignore-path #{p}" }.join(' ')
59
- insecure_registries_cli = Array(kaniko_opts[:insecure_registries]).map do |r|
67
+ flags.uniq.map { |f| "--#{f}" }.join(' ')
68
+ end
69
+
70
+ def options
71
+ kaniko_opts[:options].map { |k, v| "--#{k}=\"#{v}\"" }.join(' ')
72
+ end
73
+
74
+ def build_args
75
+ kaniko_opts[:build_args].map { |k, v| "--build-arg #{k}=\"#{v}\"" }.join(' ')
76
+ end
77
+
78
+ def ignore_paths
79
+ Array(kaniko_opts[:ignore_paths]).map { |p| "--ignore-path #{p}" }.join(' ')
80
+ end
81
+
82
+ def insecure_registries
83
+ Array(kaniko_opts[:insecure_registries]).map do |r|
60
84
  "--insecure-registry #{r}"
61
85
  end.join(' ')
62
- registry_certificates_cli = kaniko_opts[:registry_certificates].map do |k, v|
86
+ end
87
+
88
+ def registry_certificates
89
+ kaniko_opts[:registry_certificates].map do |k, v|
63
90
  "--registry-certificate #{k}=\"#{v}\""
64
91
  end.join(' ')
65
- registry_mirrors_cli = Array(kaniko_opts[:registry_mirrors]).map { |r| "--registry-mirror #{r}" }.join(' ')
66
- skip_tls_verify_registrys_cli = Array(kaniko_opts[:skip_tls_verify_registry]).map do |r|
92
+ end
93
+
94
+ def registry_mirrors
95
+ Array(kaniko_opts[:registry_mirrors]).map { |r| "--registry-mirror #{r}" }.join(' ')
96
+ end
97
+
98
+ def destinations
99
+ kaniko_opts[:destinations].map { |d| "--destination #{d}" }.join(' ')
100
+ end
101
+
102
+ def skip_tls_verify_registries
103
+ Array(kaniko_opts[:skip_tls_verify_registry]).map do |r|
67
104
  "--skip-tls-verify-registry #{r}"
68
105
  end.join(' ')
69
- destinations_cli = kaniko_opts[:destinations].map { |d| "--destination #{d}" }.join(' ')
70
- kaniko_cmds = [
71
- "\"#{kaniko_opts[:executor]}\"",
72
- '--context',
73
- "\"#{kaniko_opts[:context]}\"",
74
- '--dockerfile',
75
- "\"#{kaniko_opts[:dockerfile]}\"",
76
- flags_cli,
77
- options_cli,
78
- build_args_cli,
79
- ignore_paths_cli,
80
- insecure_registries_cli,
81
- registry_certificates_cli,
82
- registry_mirrors_cli,
83
- destinations_cli,
84
- skip_tls_verify_registrys_cli
85
- ].reject(&:empty?)
86
- script << kaniko_cmds.join(' ')
87
- opts[:script] = script
88
106
  end
89
107
  end
90
108
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # qualys_scan_job class
5
+ class QualysScanJob < Job
6
+ attr_accessor :qualys_opts
7
+
8
+ def initialize(name = 'build', opts = {}, qualys_opts = {})
9
+ @qualys_opts = {
10
+ acceptable_risk: '${QUALYS_ACCEPTABLE_IMAGE_RISK}',
11
+ artifacts: {
12
+ expire_in: '1 month', paths: ['software.json', 'vulnerabilities.json'], when: 'always'
13
+ }, debug: true, gateway: '${QUALYS_GATEWAY}', image: nil, password: '${QUALYS_PASSWORD}', rules: nil,
14
+ scan_image: '${QUALYS_IMAGE}', scan_target_prefix: 'qualys_scan_target', tags: nil, user: '${QUALYS_USERNAME}',
15
+ variables: { GIT_STRATEGY: 'clone' }
16
+ }.merge(qualys_opts)
17
+ super name, opts
18
+ update
19
+ end
20
+
21
+ def update # rubocop:disable Metrics/AbcSize
22
+ require 'json'
23
+ opts[:artifacts] = qualys_opts[:artifacts] if qualys_opts[:artifacts]
24
+ opts[:image] = qualys_opts[:image]
25
+ opts[:rules] = qualys_opts[:rules] if qualys_opts[:rules]
26
+ opts[:tags] = qualys_opts[:tags] if qualys_opts[:tags]
27
+ opts[:variables] = qualys_opts[:variables] if qualys_opts[:variables]
28
+ opts[:script] = debug + image + token + scan_start + scan_complete + artifacts + severities + outputs
29
+ end
30
+
31
+ private
32
+
33
+ def debug # rubocop:disable Metrics/MethodLength
34
+ if qualys_opts[:debug]
35
+ Pipedawg::Util.echo_proxy_vars + [
36
+ 'echo Qualys settings:',
37
+ "echo Qualys gateway: \"#{qualys_opts[:gateway]}\"",
38
+ "echo Qualys username: \"#{qualys_opts[:user]}\"",
39
+ "if [ \"#{qualys_opts[:password]}\" != '' ]; then " \
40
+ 'echo Qualys password is not empty; else ' \
41
+ 'echo Qualys password is not set; exit 1; fi'
42
+ ]
43
+ else
44
+ []
45
+ end
46
+ end
47
+
48
+ def image
49
+ [
50
+ "image_target=\"#{qualys_opts[:scan_target_prefix]}:$(echo #{qualys_opts[:scan_image]} | sed 's/^[^/]*\\///'| sed 's/[:/]/-/g')\"", # rubocop:disable Layout/LineLength
51
+ "docker pull \"#{qualys_opts[:scan_image]}\"",
52
+ "docker image tag \"#{qualys_opts[:scan_image]}\" \"${image_target}\"",
53
+ "image_id=$(docker inspect --format=\"{{index .Id}}\" \"#{qualys_opts[:scan_image]}\" | cut -c8-19)",
54
+ 'echo "Image ID: ${image_id}"'
55
+ ]
56
+ end
57
+
58
+ def token
59
+ ["token=$(curl -s --location --request POST \"https://#{qualys_opts[:gateway]}/auth\" --header \"Content-Type: application/x-www-form-urlencoded\" --data-urlencode \"username=#{qualys_opts[:user]}\" --data-urlencode \"password=#{qualys_opts[:password]}\" --data-urlencode \"token=true\")"] # rubocop:disable Layout/LineLength
60
+ end
61
+
62
+ def scan_start
63
+ [
64
+ 'while true; do ' \
65
+ "result=$(curl -s -o /dev/null -w ''%{http_code}'' --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id\" --header \"Authorization: Bearer $token\"); " + # rubocop:disable Layout/LineLength, Style/FormatStringToken
66
+ 'echo "Waiting for scan to start..."; ' \
67
+ 'echo " Result: ${result}"; ' \
68
+ 'if [ "${result}" = "200" ]; then break; fi; ' \
69
+ 'sleep 10; done'
70
+ ]
71
+ end
72
+
73
+ def scan_complete
74
+ [
75
+ 'while true; do ' \
76
+ "result=$(curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id\" --header \"Authorization: Bearer $token\" | jq -r '.scanStatus'); " + # rubocop:disable Layout/LineLength
77
+ 'echo "Waiting for scan to complete..."; ' \
78
+ 'echo " Result: ${result}"; ' \
79
+ 'if [ "${result}" = "SUCCESS" ]; then break; fi; ' \
80
+ 'sleep 10; done; sleep 30'
81
+ ]
82
+ end
83
+
84
+ def artifacts
85
+ [
86
+ "curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/software\" --header \"Authorization: Bearer $token\" | jq . > software.json", # rubocop:disable Layout/LineLength
87
+ "curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/vuln\" --header \"Authorization: Bearer $token\" | jq . > vulnerabilities.json" # rubocop:disable Layout/LineLength
88
+ ]
89
+ end
90
+
91
+ def severities
92
+ [
93
+ "response=$(curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/vuln/count\" --header \"Authorization: Bearer $token\")", # rubocop:disable Layout/LineLength
94
+ 'severity5=$(jq -r ".severity5Count" <<< "${response}")',
95
+ 'severity4=$(jq -r ".severity4Count" <<< "${response}")'
96
+ ]
97
+ end
98
+
99
+ def outputs # rubocop:disable Metrics/MethodLength
100
+ [
101
+ 'if [ "$severity5" = "null" ]; then ' \
102
+ 'echo "ERROR: Wrong ImageID or problem during vulnerabilities count." >&2; ' \
103
+ 'exit 1; fi',
104
+ 'if [ "$severity4" = "null" ]; then ' \
105
+ 'echo "ERROR: Wrong ImageID or problem during vulnerabilities count." >&2; ' \
106
+ 'exit 1; fi',
107
+ 'echo "Severity5: $severity5, Severity4: $severity4"',
108
+ 'risk=$((($severity5*3)+($severity4)))',
109
+ 'echo "Risk: $risk"',
110
+ "if (($risk > \"#{qualys_opts[:acceptable_risk]}\")); then " \
111
+ 'echo "Too many vulnerabilities. Severity5: $severity5, Severity4: $severity4" >&2; ' \
112
+ 'exit 1; fi'
113
+ ]
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # util class
5
+ class Util
6
+ def self.expand_env_vars(item) # rubocop:disable Metrics/MethodLength
7
+ case item
8
+ when Array
9
+ item.map { |i| expand_env_vars(i) }
10
+ when Hash
11
+ item.each { |k, v| item[k] = expand_env_vars(v) }
12
+ item
13
+ when String
14
+ item.gsub(/\${([^} ]+)}/) do |e|
15
+ ENV[e.gsub('${', '').gsub('}', '')]
16
+ end
17
+ else
18
+ item
19
+ end
20
+ end
21
+
22
+ def self.puts_proxy_vars
23
+ puts 'Proxy settings:'
24
+ puts "http_proxy: #{ENV['http_proxy']}"
25
+ puts "https_proxy: #{ENV['https_proxy']}"
26
+ puts "no_proxy: #{ENV['no_proxy']}"
27
+ puts "HTTP_PROXY: #{ENV['HTTP_PROXY']}"
28
+ puts "HTTPS_PROXY: #{ENV['HTTPS_PROXY']}"
29
+ puts "NO_PROXY: #{ENV['NO_PROXY']}"
30
+ end
31
+
32
+ def self.echo_proxy_vars
33
+ script = ['echo Proxy settings:']
34
+ script << 'echo http_proxy: "${http_proxy}"'
35
+ script << 'echo https_proxy: "${https_proxy}"'
36
+ script << 'echo no_proxy: "${no_proxy}"'
37
+ script << 'echo HTTP_PROXY: "${HTTP_PROXY}"'
38
+ script << 'echo HTTPS_PROXY: "${HTTPS_PROXY}"'
39
+ script << 'echo NO_PROXY: "${NO_PROXY}"'
40
+ script
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pipedawg
4
- VERSION = '0.2.2'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/pipedawg.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pipedawg/job'
4
+ require 'pipedawg/helm_copy_job'
4
5
  require 'pipedawg/kaniko_job'
5
6
  require 'pipedawg/pipeline'
7
+ require 'pipedawg/qualys_scan_job'
8
+ require 'pipedawg/util'
6
9
  require 'pipedawg/version'
7
10
 
8
11
  module Pipedawg
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pipedawg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - harbottle
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-02-01 00:00:00.000000000 Z
11
+ date: 2022-02-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Generate GitLab CI pipelines.
14
14
  email:
@@ -20,9 +20,12 @@ files:
20
20
  - LICENSE.txt
21
21
  - README.md
22
22
  - lib/pipedawg.rb
23
+ - lib/pipedawg/helm_copy_job.rb
23
24
  - lib/pipedawg/job.rb
24
25
  - lib/pipedawg/kaniko_job.rb
25
26
  - lib/pipedawg/pipeline.rb
27
+ - lib/pipedawg/qualys_scan_job.rb
28
+ - lib/pipedawg/util.rb
26
29
  - lib/pipedawg/version.rb
27
30
  homepage: https://github.com/liger1978/pipedawg
28
31
  licenses: