├── Procfile ├── project ├── persistence.py ├── template │ ├── profile.html │ ├── index.html │ ├── items.html │ ├── login.html │ ├── signup.html │ └── base.html ├── models.py ├── main.py ├── queries.py ├── commands.py ├── __init__.py └── auth.py ├── manifest.yml ├── Pipfile ├── requirements.txt ├── README.md ├── run-via-docker.sh ├── .github └── workflows │ ├── pr.yml │ ├── sbom.yml │ ├── main.yml │ └── codeql-analysis.yml ├── LICENSE.md ├── docs └── SBOM.md └── Pipfile.lock /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn "project:create_app()" -------------------------------------------------------------------------------- /project/persistence.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() -------------------------------------------------------------------------------- /project/template/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | Welcome, {{ name }}! 6 |

7 | {% endblock %} -------------------------------------------------------------------------------- /project/template/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | Flask Login Example 6 |

7 |

8 | Easy authentication and authorization in Flask. 9 |

10 | {% endblock %} -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: 10x-dux-app 4 | random-route: true 5 | buildpacks: 6 | - https://github.com/18f/vuls-cloudfoundry-buildpack.git#master 7 | - python_buildpack 8 | memory: 128M 9 | env: 10 | FLASK_APP: project 11 | VULS_HTTP_SERVER: ((vuls_http_server)) 12 | CREATE_DATA: 1 13 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | flake8 = "==3.8.3" 8 | 9 | [packages] 10 | tortoise-orm = "==0.16.6" 11 | flask = "==1.1.2" 12 | flask-login = "==0.5.0" 13 | flask-sqlalchemy = "==2.4.4" 14 | gunicorn = "==20.0.4" 15 | sqlalchemy = "==1.3.18" 16 | 17 | [requires] 18 | python_version = "3.8" 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | aiosqlite==0.15.0 3 | ciso8601==2.1.3 4 | click==7.1.2 5 | flask-login==0.5.0 6 | flask-sqlalchemy==2.4.4 7 | flask==1.1.2 8 | gunicorn==20.0.4 9 | itsdangerous==1.1.0 10 | jinja2==2.11.2 11 | markupsafe==1.1.1 12 | pypika==0.38.0 13 | sqlalchemy==1.3.18 14 | tortoise-orm==0.16.6 15 | typing-extensions==3.7.4.2 16 | werkzeug==1.0.1 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 10x-dux-app 2 | 3 | The 10x Dependency Upgrades eXample App 4 | 5 | **NOTE:** This code is only a component for the 10x Dependency Upgrades Project, please visit [the main repo and documentation for more details](https://github.com/18F/10x-dux-vuls-eval/blob/master/README.md#background). 6 | 7 | # What? 8 | 9 | It is a simple Python API server that will use purposefully expose a known 10 | vulnerability. 11 | -------------------------------------------------------------------------------- /run-via-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A script to start a Docker image, passing existing AWS credentials, and pass a command to the image 3 | 4 | # Obtain the directory of the script 5 | SCRIPT_DIR=$(pwd) 6 | 7 | DOCKER_ENV="" 8 | for VAR in $(printenv | egrep -e '^INPUT_'); do 9 | echo "Addinng ${VAR}" 10 | DOCKER_ENV="-e ${VAR} ${DOCKER_ENV}" 11 | done 12 | 13 | echo "Shell out to the provision docker container" 14 | docker run -it ${DOCKER_ENV} -v "$(pwd):/app" -w /app --entrypoint /bin/bash cg-cli-tools "$@" 15 | -------------------------------------------------------------------------------- /project/template/items.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | {{ title }} 6 |

7 |

8 | A collection of {{ title.lower() }}. 9 |

10 |
11 | 19 |
20 |
21 | {% if groupings %}Order by:{% endif %} 22 | {% for o in orders %} {{ o }} {% endfor %} 23 |
24 | {% endblock %} -------------------------------------------------------------------------------- /project/models.py: -------------------------------------------------------------------------------- 1 | from flask_login import UserMixin 2 | from .persistence import db 3 | 4 | class User(UserMixin, db.Model): 5 | id = db.Column(db.Integer, primary_key=True) 6 | email = db.Column(db.String(100), unique=True) 7 | password = db.Column(db.String(100)) 8 | name = db.Column(db.String(1000)) 9 | 10 | class PublicItem(db.Model): 11 | id = db.Column(db.Integer, primary_key=True) 12 | key = db.Column(db.String(100)) 13 | value = db.Column(db.String(100)) 14 | 15 | class SensitiveItem(db.Model): 16 | id = db.Column(db.Integer, primary_key=True) 17 | key = db.Column(db.String(100)) 18 | value = db.Column(db.String(100)) -------------------------------------------------------------------------------- /project/main.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request 2 | from flask_login import login_required, current_user 3 | from .queries import * 4 | 5 | main = Blueprint('main', __name__, static_folder='static', template_folder='template') 6 | 7 | @main.route('/') 8 | def index(): 9 | return render_template('index.html') 10 | 11 | @main.route('/public') 12 | def public(): 13 | return render_template( 14 | 'items.html', 15 | title='Public Items', 16 | items=get_public_items( 17 | order=request.args.get('order_by') 18 | ), 19 | orders=get_public_items_orders(), 20 | ) 21 | 22 | @main.route('/sensitive') 23 | @login_required 24 | def sensitive(): 25 | return render_template( 26 | 'items.html', 27 | title='Sensitive Items', 28 | items=get_sensitve_items( 29 | order=request.args.get('order_by') 30 | ), 31 | orders=get_sensitive_items_orders() 32 | ) 33 | 34 | @main.route('/profile') 35 | @login_required 36 | def profile(): 37 | return render_template('profile.html', name=current_user.name) 38 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test Pull Requests 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - develop 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 3.8 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: 3.8 20 | - name: Set up pipenv 21 | uses: dschep/install-pipenv-action@aaac0310d5f4a052d150e5f490b44354e08fbb8c 22 | with: 23 | version: 2020.6.2 24 | - name: Lint with flake8 25 | run: | 26 | pipenv install --dev 27 | # stop the build if there are Python syntax errors or undefined names 28 | pipenv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | pipenv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 31 | -------------------------------------------------------------------------------- /project/queries.py: -------------------------------------------------------------------------------- 1 | from .models import PublicItem, SensitiveItem, User 2 | from .persistence import db 3 | 4 | def get_user(user_id): 5 | if not user_id: 6 | return None 7 | 8 | if not isinstance(user_id, int): 9 | return None 10 | 11 | result = db.session().query(User).get(user_id) 12 | print(result) 13 | return result 14 | 15 | def filter_by_user(email): 16 | if not email: 17 | return None 18 | 19 | result = db.session().query(User).filter_by(email=email).first() 20 | print(result) 21 | return result 22 | 23 | def get_public_items(order=None): 24 | if order: 25 | return db.session().query(PublicItem).order_by(order).all() 26 | 27 | return db.session().query(PublicItem).all() 28 | 29 | def get_public_items_orders(): 30 | model = PublicItem 31 | return [m.key for m in model.__table__.columns] 32 | 33 | def get_sensitve_items(order=None): 34 | if order: 35 | return db.session().query(SensitiveItem).order_by(order).all() 36 | 37 | return db.session().query(SensitiveItem).all() 38 | 39 | def get_sensitive_items_orders(): 40 | model = SensitiveItem 41 | return [m.key for m in model.__table__.columns] -------------------------------------------------------------------------------- /project/commands.py: -------------------------------------------------------------------------------- 1 | from .models import PublicItem, SensitiveItem, User 2 | from .persistence import db 3 | 4 | def generate_public_items(ctx): 5 | try: 6 | with ctx: 7 | for n in range(25): 8 | db.session.add( 9 | PublicItem( 10 | key=f"PublicKey{n}", 11 | value=f"PublicValue{n}" 12 | ) 13 | ) 14 | db.session.commit() 15 | 16 | except Exception as err: 17 | return False 18 | 19 | return True 20 | 21 | def generate_sensitive_items(ctx): 22 | try: 23 | with ctx: 24 | for n in range(25): 25 | db.session.add( 26 | SensitiveItem( 27 | key=f"SensitiveKey{n}", 28 | value=f"SensitiveValue{n}" 29 | ) 30 | ) 31 | db.session.commit() 32 | 33 | except Exception as err: 34 | return False 35 | 36 | return True 37 | 38 | def add_user(email, name, password): 39 | db.session.add( 40 | User(email=email, 41 | name=name, 42 | password=password 43 | ) 44 | ) 45 | 46 | db.session.commit() -------------------------------------------------------------------------------- /project/template/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Login

6 |
7 | {% with messages = get_flashed_messages() %} 8 | {% if messages %} 9 |
10 | {{ messages[0] }} 11 |
12 | {% endif %} 13 | {% endwith %} 14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 | 31 |
32 | 33 |
34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /.github/workflows/sbom.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Generate SBOM Report 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | analyze-sbom: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 3.8 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: 3.8 20 | - name: Set up pipenv 21 | uses: dschep/install-pipenv-action@aaac0310d5f4a052d150e5f490b44354e08fbb8c 22 | with: 23 | version: 2020.6.2 24 | - name: Install dependencies and generate in place requirements.txt file 25 | run: | 26 | pipenv install --dev 27 | pipenv lock --requirements > requirements.txt 28 | - name: Generate CycloneDX SBOM report 29 | uses: CycloneDX/gh-python-generate-sbom@9847fabb5866e97354c28fe5f1d6fa8b71e3b38d # current v1 tag 30 | - name: Upload CycloneDX report to project artifacts 31 | uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6 # current v2 tag 32 | with: 33 | name: 10-dux-app-${{ github.sha }}-sbom-cyclonedx 34 | path: ./bom.xml 35 | if-no-files-found: error -------------------------------------------------------------------------------- /project/template/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Sign Up

6 |
7 | {% with messages = get_flashed_messages() %} 8 | {% if messages %} 9 |
10 | {{ messages[0] }}. Go to login page. 11 |
12 | {% endif %} 13 | {% endwith %} 14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 | {% endblock %} -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal summary 8 | 9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to 14 | the public domain by waiving all rights to the work worldwide 15 | under copyright law, including all related and neighboring rights, to the 16 | extent allowed by law. 17 | 18 | You can copy, modify, distribute and perform the work, even for commercial 19 | purposes, all without asking permission. 20 | 21 | ### Other information 22 | 23 | In no way are the patent or trademark rights of any person affected by CC0, 24 | nor are the rights that other persons may have in the work or in how the 25 | work is used, such as publicity or privacy rights. 26 | 27 | Unless expressly stated otherwise, the person who associated a work with 28 | this deed makes no warranties about the work, and disclaims liability for 29 | all uses of the work, to the fullest extent permitted by applicable law. 30 | When using or citing the work, you should not imply endorsement by the 31 | author or the affirmer. 32 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, redirect, url_for 2 | from flask_login import LoginManager 3 | from flask_sqlalchemy import SQLAlchemy 4 | from os import environ 5 | from .queries import get_user 6 | from .commands import generate_public_items, generate_sensitive_items 7 | from .persistence import db 8 | 9 | def create_app_data(app, db): 10 | with app.app_context(): 11 | db.create_all() 12 | 13 | if not generate_public_items(app.app_context()): 14 | raise RuntimeError('Data generation failed') 15 | 16 | if not generate_sensitive_items(app.app_context()): 17 | raise RuntimeError('Data generation failed') 18 | 19 | def create_app(): 20 | app = Flask(__name__) 21 | app.config['SECRET_KEY'] = environ.get('SECRET_KEY', 'LOCALDEVONLY') 22 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' 23 | app.config['SQLALCHEMY_ECHO'] = True 24 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True 25 | 26 | db.init_app(app) 27 | 28 | from . import models 29 | 30 | if environ.get('CREATE_DATA', False): 31 | create_app_data(app, db) 32 | 33 | login_manager = LoginManager() 34 | login_manager.login_view = 'auth.login' 35 | login_manager.init_app(app) 36 | 37 | @login_manager.user_loader 38 | def load_user(user_id): 39 | print('User loaded!') 40 | return get_user(user_id) 41 | 42 | from .auth import auth as auth_blueprint 43 | app.register_blueprint(auth_blueprint) 44 | 45 | from .main import main as main_blueprint 46 | app.register_blueprint(main_blueprint) 47 | 48 | return app 49 | -------------------------------------------------------------------------------- /project/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, redirect, url_for, request, flash 2 | from flask_login import login_user, logout_user 3 | from werkzeug.security import generate_password_hash, check_password_hash 4 | from .commands import add_user 5 | from .models import User 6 | from .queries import filter_by_user 7 | 8 | auth = Blueprint('auth', __name__) 9 | 10 | @auth.route('/login') 11 | def login(): 12 | return render_template('login.html') 13 | 14 | @auth.route('/login', methods=['POST']) 15 | def login_post(): 16 | email = request.form.get('email') 17 | password = request.form.get('password') 18 | remember = True if request.form.get('remember') else False 19 | 20 | user = filter_by_user(email) 21 | 22 | if not user or not check_password_hash(user.password, password): 23 | flash('Please check your login details and try again.') 24 | return redirect(url_for('auth.login')) 25 | 26 | login_user(user, remember=remember) 27 | return redirect(url_for('main.index')) 28 | 29 | @auth.route('/signup') 30 | def signup(): 31 | return render_template('signup.html') 32 | 33 | @auth.route('/signup', methods=['POST']) 34 | def signup_post(): 35 | email = request.form.get('email') 36 | name = request.form.get('name') 37 | password = request.form.get('password') 38 | 39 | user = filter_by_user(email) 40 | 41 | if user: 42 | flash('Email address already exists') 43 | return redirect(url_for('auth.signup')) 44 | 45 | new_user = add_user( 46 | email, 47 | name, 48 | generate_password_hash( 49 | password, 50 | method='pbkdf2:sha256', 51 | salt_length=8 52 | ) 53 | ) 54 | 55 | return redirect(url_for('auth.login')) 56 | 57 | 58 | @auth.route('/logout') 59 | def logout(): 60 | logout_user() 61 | return redirect(url_for('main.index')) -------------------------------------------------------------------------------- /project/template/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Flask Auth Example 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | 46 |
47 | 48 |
49 |
50 | {% block content %} 51 | {% endblock %} 52 |
53 |
54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Deploy 5 | 6 | on: 7 | push: 8 | branches: 9 | - develop 10 | - master 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.8 21 | - name: Set up pipenv 22 | uses: dschep/install-pipenv-action@aaac0310d5f4a052d150e5f490b44354e08fbb8c 23 | with: 24 | version: 2020.6.2 25 | - name: Lint with flake8 26 | run: | 27 | pipenv install --dev 28 | # stop the build if there are Python syntax errors or undefined names 29 | pipenv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 30 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 31 | pipenv run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 32 | 33 | deploy: 34 | runs-on: ubuntu-latest 35 | needs: test 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Deploy to cloud.gov (DEV) 39 | if: github.ref == 'refs/heads/develop' 40 | uses: cloud-gov/cg-cli-tools@faa8ea4e4c784e50ddddc6859b382efaad9774b9 41 | with: 42 | cf_api: ${{ secrets.CG_ENDPOINT }} 43 | cf_username: ${{ secrets.CG_USERNAME }} 44 | cf_password: ${{ secrets.CG_PASSWORD }} 45 | cf_org: ${{ secrets.CG_ORG }} 46 | cf_space: ${{ secrets.CG_SPACE }} 47 | cf_command: push -f manifest.yml 10x-dux-app-dev1 --var vuls_http_server=${{ secrets.VULS_HTTP_SERVER }} 48 | - name: Deploy to cloud.gov (UAT) 49 | if: github.ref == 'refs/heads/master' 50 | uses: cloud-gov/cg-cli-tools@faa8ea4e4c784e50ddddc6859b382efaad9774b9 51 | with: 52 | cf_api: ${{ secrets.CG_ENDPOINT }} 53 | cf_username: ${{ secrets.CG_USERNAME }} 54 | cf_password: ${{ secrets.CG_PASSWORD }} 55 | cf_org: ${{ secrets.CG_ORG }} 56 | cf_space: ${{ secrets.CG_SPACE }} 57 | cf_command: push -f manifest.yml 10x-dux-app-uat1 --var vuls_http_server=${{ secrets.VULS_HTTP_SERVER }} 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [develop, master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [develop] 14 | schedule: 15 | - cron: '0 4 * * 3' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /docs/SBOM.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | This documentation explains what, why, and how to use Github Actions to derive 4 | a Software Bill of Materials for this and similar applications deployed within 5 | the github.com ecosystem for the GSA and 18F organizations. 6 | 7 | # What? 8 | 9 | Per the Department of Commerce's National Telecommunications and Information 10 | Administration leading the SBOM adoption effort in the US public and private 11 | sector, an SBOM is the "formal record containing the details and supply chain 12 | relationships of various components used in building software. ​An SBOM is 13 | effectively a nested inventory: a list of ingredients that make up software 14 | components." [1](fn1) 15 | 16 | # How? 17 | 18 | ## Determine Language Runtimes Used in the Project 19 | 20 | A developer of a new or existing project will have to itemize the one or more 21 | programming language(s) the project uses or will use. The Github Actions that 22 | CycloneDX developers are developing target a specific language to analyze its 23 | respective package manager and generate a SBOM from the package manager manifest. 24 | 25 | At the time of this writing, in October 2020, the CycloneDX community supports 26 | the following language runtimes with pre-made Github Actions. 27 | 28 | - [.NET `.sln`, `.csproj`, and `packages.config`](https://github.com/CycloneDX/gh-dotnet-generate-sbom) 29 | - [NodeJS](https://github.com/CycloneDX/gh-node-module-generatebom) 30 | - [Python `requirements.txt`](https://github.com/CycloneDX/gh-python-generate-sbom) 31 | - [PHP Composer](https://github.com/CycloneDX/gh-php-composer-generate-sbom) 32 | 33 | For this example repository, we have chosen Python. 34 | 35 | A final example for this repo is in [`.github/workflows/sbom.yml`](https://github.com/18F/10x-dux-app/blob/19e15fe9c20dbb543b3baefdadcd7921b3795898/.github/workflows/sbom.yml). 36 | 37 | ## Create a Github Action Configuration 38 | 39 | Once you determine the languages you need to add you will create the action file. 40 | 41 | Inside the directory, you will create a new Github Action like so. 42 | 43 | ```sh 44 | mkdir -p path/to/your/repo/.github/workflows 45 | ``` 46 | 47 | ## Determine Code Changes That Trigger SBOM Analysis 48 | 49 | For any Github Action, and especially SBOM analysis, you must determine which code 50 | changes trigger a certain job. With `git` and Github, this can focus on branches 51 | and pull requests as the fundamental way of organizing code collections and 52 | collaborative review by a development team. 53 | 54 | Therefore, we begin by analyzing any pull request to merge code in any branch 55 | with any name. 56 | 57 | ```yaml 58 | name: Generate SBOM Report 59 | 60 | on: 61 | pull_request: 62 | branches: 63 | - '*' 64 | ``` 65 | 66 | ## Add Checkout Component to the Action Configuration 67 | 68 | You must first add a step to check out the source code into the Github Action 69 | runner for analysis. 70 | 71 | ```yaml 72 | jobs: 73 | analyze-sbom: 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v2 77 | ``` 78 | 79 | ## Add Language Package Manager Setup 80 | 81 | These important steps are specific to the language runtime(s). If you select 82 | Python from the list of supported languages above, you will potentially use 83 | `pipenv` job steps to bootstrap requirements and create a `requirements.txt` 84 | file. 85 | 86 | ```yaml 87 | - name: Set up Python 3.8 88 | uses: actions/setup-python@v1 89 | with: 90 | python-version: 3.8 91 | - name: Set up pipenv 92 | uses: dschep/install-pipenv-action@aaac0310d5f4a052d150e5f490b44354e08fbb8c 93 | with: 94 | version: 2020.6.2 95 | - name: Install dependencies and generate in place requirements.txt file 96 | run: | 97 | pipenv install --dev 98 | pipenv lock --requirements > requirements.txt 99 | ``` 100 | 101 | The need to duplicate this information from `Pipfile` to `requirements.txt` in this 102 | case is specific to additional tooling requirements for 10x Dependency Upgrades 103 | and research into differential requirements of dependency scanners. If a 104 | development team uses `requirements.txt` exclusively without any use of `pipenv`, 105 | the final two of three steps can be skipped. 106 | 107 | ```yaml 108 | - name: Set up Python 3.8 109 | uses: actions/setup-python@v1 110 | with: 111 | python-version: 3.8 112 | ``` 113 | 114 | ## Analyze the Package Manager Manifest to Generate a SBOM 115 | 116 | This step will process a Python requirements file to actually build the SBOM. 117 | 118 | A developer team may need to customize the file paths or other options of the 119 | scan, and it is best they refer to this [upstream documentation](https://github.com/CycloneDX/gh-python-generate-sbom/blob/master/README.md). 120 | 121 | 122 | ```yaml 123 | - name: Generate CycloneDX SBOM report 124 | uses: CycloneDX/gh-python-generate-sbom@9847fabb5866e97354c28fe5f1d6fa8b71e3b38d # current v1 tag 125 | ``` 126 | 127 | If your repo is exclusively Javascript, you would replace the above with the 128 | following example. 129 | 130 | ```yaml 131 | - name: Generate CycloneDX SBOM report 132 | uses: CycloneDX/gh-node-module-generatebom@b5753d516608ed84f7a40eb19b7687b5828b9b2d # current v1 tag 133 | ``` 134 | 135 | If your project includes both Python and Javascript, you would add this step, 136 | specific to Javascript, beneath the previous Python-specific steps to obtain 137 | SBOM coverage for both languages. 138 | 139 | ## Store Results in Github Artifacts for Later Retrieval and Analysis 140 | 141 | The final step in the job stores the resulting SBOM in CycloneDX's XML format to 142 | the Github Artifacts for your repository as a compressed ZIP archive, including 143 | a hash in the archive's name to uniquely fingerprinting the sum of code analyzed 144 | using `git` internals. Storing this within the repo will allow development teams 145 | to maintain a historical list of changes and ease further integration upstream 146 | with one or more Software Composition Analysis tools. This integration can be 147 | done manually, but also in bulk, thanks to the capable APIs provided by [Github for Artifacts](https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#artifacts) 148 | and its other features. Additionally, using the native Github CI/CD system 149 | without additional authentication tokens means convenient storage without 150 | additional configuration and/or security engineering concerns. 151 | 152 | As configured below with `if-no-files-found` a CI job will fail if a file is not 153 | generated. This configuration can be further enhanced to prevent pull requests 154 | where this Github Actions workflow fails, thereby preventing any promotion of 155 | code to a known branch that cannot generate a valid SBOM. 156 | 157 | One successful CI run with an example report [can be retrieved here](https://github.com/18F/10x-dux-app/suites/1273719661/artifacts/19646001). 158 | 159 | ```yaml 160 | - name: Upload CycloneDX report to project artifacts 161 | uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6 # current v2 tag 162 | with: 163 | name: 10-dux-app-${{ github.sha }}-sbom-cyclonedx 164 | path: ./bom.xml 165 | if-no-files-found: error 166 | ``` 167 | 168 | # Why? 169 | 170 | ## Why SBOM in General? 171 | 172 | There are a multitude of reasons for supporting SBOM, which the NTIA outlines 173 | in detail in their whitepaper about the role and benefits of its adoption. [2](fn2) 174 | 175 | ## Why CycloneDX over Other Formats? 176 | 177 | There are multiple popular SBOM standards with variable acceptance in the 178 | software industry at large, per the NTIA 2019 survey of the most common.[3](fn3) 179 | 180 | - Conside SWID (a.k.a CoSWID) 181 | - CycloneDX 182 | - SWID 183 | - SPDX 184 | 185 | We aspired to achieve the following high-level requirements: 186 | 187 | - Use free (cost and/or licensing) tools that required minimal or no additional 188 | cost or service approvals for developers working on GSA projects. 189 | - Use tools with the least amount of time required to develop a custom solution 190 | or augment existing tooling. 191 | - Use tools that, by default or with the least amount of custom development, 192 | supported the largest number of programming language ecosystems that GSA 193 | development teams use. 194 | - Use a tool that best adopts or extends formats, workflows, and concepts the 195 | standard and its tools share with other popular development tools in GSA. 196 | 197 | With this in mind, the most readily available tools with free licenses and 198 | supporting tooling with the Github-native Actions continuous integration and 199 | continuous deployment platform is CycloneDX. 200 | 201 | # Conclusions 202 | 203 | Using Github Actions provided by CycloneDX is effective, but surfaces several 204 | shortcomings. 205 | 206 | - Freely available and open-source tooling is designed for use with specific 207 | language runtimes. 208 | - The burden to create and maintain tooling for ecosystems as new languages 209 | rise and fall in popularity, like vendors of proprietary software, have 210 | minor or significant lags in development. Unlike vendor solutions, there 211 | is no contractual backing or incentive for changes beyond the preference 212 | of community developers. 213 | - Development teams must manage one or more configuration for each language 214 | they use, which is increasingly onerous as the number of repos and/or 215 | languages they use in each repo increases. 216 | - Repos with one language are easy to manage, but any repo with two or more 217 | languages will require SBOM output be merged. 218 | - The easiest to implement SBOM standard with freely available or open source 219 | tooling is CycloneDX, but SWID is popular with SCA and enterprise management 220 | systems, in GSA, as well as the public and private sectors overall. SWID, and 221 | other older standards, do not have reliable open source utilities and/or 222 | readily available Github Actions. Custom development will be required. 223 | - All configuration is focused per repository, as this is the easiest point of 224 | integration. Although convenient, that means automating bulk adoption will be 225 | difficult as engineers will need knowledge of languages and configuration for 226 | each repo and need to be able to code that to ease bootstrapping repos in bulk. 227 | A solution that will make best effort attempts at auto-detection will be most 228 | welcome or a proprietary alternative will be required. 229 | - No accessible service exists to aggregate the resulting SBOMs to analyze them 230 | as a whole for statistics or higher-level patterns. This prototype simply 231 | stores them. Future work is needed to evaluate deployment of an open-source 232 | solution or evaluate a propriety solution, either SaaS or on-premises. 233 | 234 | 1: https://www.ntia.gov/files/ntia/publications/sbom_overview_20200818.pdf 235 | 236 | 2: https://www.ntia.gov/files/ntia/publications/ntia_sbom_use_cases_roles_benefits-nov2019.pdf 237 | 238 | 3: https://www.ntia.gov/files/ntia/publications/ntia_sbom_formats_and_standards_whitepaper_-_version_20191025.pdf 239 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "554c73514355a9b0bbfdf89104ee065c46e0a9610de93b57bc3428ff6021d79f" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiosqlite": { 20 | "hashes": [ 21 | "sha256:19b984b6702aed9f1c85c023f37296954547fc4030dae8e9d027b2a930bed78b", 22 | "sha256:a2884793f4dc8f2798d90e1dfecb2b56a6d479cf039f7ec52356a7fd5f3bdc57" 23 | ], 24 | "version": "==0.15.0" 25 | }, 26 | "ciso8601": { 27 | "hashes": [ 28 | "sha256:bdbb5b366058b1c87735603b23060962c439ac9be66f1ae91e8c7dbd7d59e262" 29 | ], 30 | "version": "==2.1.3" 31 | }, 32 | "click": { 33 | "hashes": [ 34 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 35 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 36 | ], 37 | "version": "==7.1.2" 38 | }, 39 | "flask": { 40 | "hashes": [ 41 | "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", 42 | "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" 43 | ], 44 | "index": "pypi", 45 | "version": "==1.1.2" 46 | }, 47 | "flask-login": { 48 | "hashes": [ 49 | "sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b", 50 | "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0" 51 | ], 52 | "index": "pypi", 53 | "version": "==0.5.0" 54 | }, 55 | "flask-sqlalchemy": { 56 | "hashes": [ 57 | "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", 58 | "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" 59 | ], 60 | "index": "pypi", 61 | "version": "==2.4.4" 62 | }, 63 | "gunicorn": { 64 | "hashes": [ 65 | "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", 66 | "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" 67 | ], 68 | "index": "pypi", 69 | "version": "==20.0.4" 70 | }, 71 | "itsdangerous": { 72 | "hashes": [ 73 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 74 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 75 | ], 76 | "version": "==1.1.0" 77 | }, 78 | "jinja2": { 79 | "hashes": [ 80 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 81 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 82 | ], 83 | "version": "==2.11.2" 84 | }, 85 | "markupsafe": { 86 | "hashes": [ 87 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 88 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 89 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 90 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 91 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 92 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 93 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 94 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 95 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 96 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 97 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 98 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 99 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 100 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 101 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 102 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 103 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 104 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 105 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 106 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 107 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 108 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 109 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 110 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 111 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 112 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 113 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 114 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 115 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 116 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 117 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 118 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 119 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" 120 | ], 121 | "version": "==1.1.1" 122 | }, 123 | "pypika": { 124 | "hashes": [ 125 | "sha256:abf85d7fc3da6c4213125b58ca989a1eabfcc1e9b1f5fc3f524eba5cd7a25107" 126 | ], 127 | "version": "==0.38.0" 128 | }, 129 | "sqlalchemy": { 130 | "hashes": [ 131 | "sha256:0942a3a0df3f6131580eddd26d99071b48cfe5aaf3eab2783076fbc5a1c1882e", 132 | "sha256:0ec575db1b54909750332c2e335c2bb11257883914a03bc5a3306a4488ecc772", 133 | "sha256:109581ccc8915001e8037b73c29590e78ce74be49ca0a3630a23831f9e3ed6c7", 134 | "sha256:16593fd748944726540cd20f7e83afec816c2ac96b082e26ae226e8f7e9688cf", 135 | "sha256:427273b08efc16a85aa2b39892817e78e3ed074fcb89b2a51c4979bae7e7ba98", 136 | "sha256:50c4ee32f0e1581828843267d8de35c3298e86ceecd5e9017dc45788be70a864", 137 | "sha256:512a85c3c8c3995cc91af3e90f38f460da5d3cade8dc3a229c8e0879037547c9", 138 | "sha256:57aa843b783179ab72e863512e14bdcba186641daf69e4e3a5761d705dcc35b1", 139 | "sha256:621f58cd921cd71ba6215c42954ffaa8a918eecd8c535d97befa1a8acad986dd", 140 | "sha256:6ac2558631a81b85e7fb7a44e5035347938b0a73f5fdc27a8566777d0792a6a4", 141 | "sha256:716754d0b5490bdcf68e1e4925edc02ac07209883314ad01a137642ddb2056f1", 142 | "sha256:736d41cfebedecc6f159fc4ac0769dc89528a989471dc1d378ba07d29a60ba1c", 143 | "sha256:8619b86cb68b185a778635be5b3e6018623c0761dde4df2f112896424aa27bd8", 144 | "sha256:87fad64529cde4f1914a5b9c383628e1a8f9e3930304c09cf22c2ae118a1280e", 145 | "sha256:89494df7f93b1836cae210c42864b292f9b31eeabca4810193761990dc689cce", 146 | "sha256:8cac7bb373a5f1423e28de3fd5fc8063b9c8ffe8957dc1b1a59cb90453db6da1", 147 | "sha256:8fd452dc3d49b3cc54483e033de6c006c304432e6f84b74d7b2c68afa2569ae5", 148 | "sha256:adad60eea2c4c2a1875eb6305a0b6e61a83163f8e233586a4d6a55221ef984fe", 149 | "sha256:c26f95e7609b821b5f08a72dab929baa0d685406b953efd7c89423a511d5c413", 150 | "sha256:cbe1324ef52ff26ccde2cb84b8593c8bf930069dfc06c1e616f1bfd4e47f48a3", 151 | "sha256:d05c4adae06bd0c7f696ae3ec8d993ed8ffcc4e11a76b1b35a5af8a099bd2284", 152 | "sha256:d98bc827a1293ae767c8f2f18be3bb5151fd37ddcd7da2a5f9581baeeb7a3fa1", 153 | "sha256:da2fb75f64792c1fc64c82313a00c728a7c301efe6a60b7a9fe35b16b4368ce7", 154 | "sha256:e4624d7edb2576cd72bb83636cd71c8ce544d8e272f308bd80885056972ca299", 155 | "sha256:e89e0d9e106f8a9180a4ca92a6adde60c58b1b0299e1b43bd5e0312f535fbf33", 156 | "sha256:f11c2437fb5f812d020932119ba02d9e2bc29a6eca01a055233a8b449e3e1e7d", 157 | "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", 158 | "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" 159 | ], 160 | "index": "pypi", 161 | "version": "==1.3.18" 162 | }, 163 | "tortoise-orm": { 164 | "hashes": [ 165 | "sha256:e5fa256f9bac59b614d0afa9de2c8f2de0cd31bb018b0006bcd44fd8f4e0fc5b" 166 | ], 167 | "index": "pypi", 168 | "version": "==0.16.6" 169 | }, 170 | "typing-extensions": { 171 | "hashes": [ 172 | "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", 173 | "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", 174 | "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" 175 | ], 176 | "version": "==3.7.4.2" 177 | }, 178 | "werkzeug": { 179 | "hashes": [ 180 | "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", 181 | "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" 182 | ], 183 | "version": "==1.0.1" 184 | } 185 | }, 186 | "develop": { 187 | "flake8": { 188 | "hashes": [ 189 | "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", 190 | "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" 191 | ], 192 | "index": "pypi", 193 | "version": "==3.8.3" 194 | }, 195 | "mccabe": { 196 | "hashes": [ 197 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 198 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 199 | ], 200 | "version": "==0.6.1" 201 | }, 202 | "pycodestyle": { 203 | "hashes": [ 204 | "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", 205 | "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" 206 | ], 207 | "version": "==2.6.0" 208 | }, 209 | "pyflakes": { 210 | "hashes": [ 211 | "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", 212 | "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" 213 | ], 214 | "version": "==2.2.0" 215 | } 216 | } 217 | } 218 | --------------------------------------------------------------------------------