name: NYX Security Scan description: Runs NYX SAST scanner and posts findings as PR comment inputs: forgejo_push_token: description: Token with write:issue scope required: true repository: description: Repository in owner/name format required: true pr_number: description: PR number to comment on required: true sha: description: Commit SHA to scan required: true fail_on: description: Severity threshold (LOW, MEDIUM, HIGH, CRITICAL) required: false default: HIGH runs: using: composite steps: - name: Clone nyx from Forgejo mirror shell: bash run: | git clone --depth=1 --branch v0.7.0 \ "https://oauth2:${{ inputs.forgejo_push_token }}@bitfreedom.net/code/apunkt/nyx.git" \ .nyx-src - name: Install Rust shell: bash run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable echo "$HOME/.cargo/bin" >> $GITHUB_PATH - name: Build nyx from source shell: bash run: | cd .nyx-src cargo build --release - name: Run NYX scan id: nyx shell: bash run: | .nyx-src/target/release/nyx scan --format json --quiet > nyx-results-raw.json 2>nyx-scan.stderr python3 -c " import json, os workspace = os.environ.get('GITHUB_WORKSPACE', os.getcwd()) with open('nyx-results-raw.json') as f: findings = json.load(f) if isinstance(findings, dict): findings = findings.get('findings', []) try: with open('.nyx/triage.json') as f: triage = json.load(f) rules = triage.get('suppression_rules', []) except: rules = [] def rel_path(p): p = p.replace(workspace, '').lstrip('/') return p # Taint rule ids carry a source-location suffix, e.g. # 'taint-unsanitised-flow (source 401:5)'. Strip it so 'rule' and # 'rule_in_file' suppressions match on the base rule id (and so the # colon inside the suffix does not break the 'rule_in_file' split). def base_rule(rid): i = rid.find(' (source ') return rid[:i] if i != -1 else rid def is_suppressed(f): rule_id = base_rule(f.get('id', '')) path = rel_path(f.get('path', '')) for r in rules: by = r.get('by', '') value = r.get('value', '') if by == 'rule' and rule_id == value: return True if by == 'file' and path == value: return True if by == 'rule_in_file': parts = value.split(':', 1) if len(parts) == 2 and rule_id == parts[0] and path == parts[1]: return True return False filtered = [f for f in findings if not is_suppressed(f)] print(f'Suppressed {len(findings) - len(filtered)} of {len(findings)} findings') fail_on = os.environ.get('NYX_FAIL_ON', 'high').lower() severity_order = ['low', 'medium', 'high', 'critical'] fail_idx = severity_order.index(fail_on) if fail_on in severity_order else 2 results = [] for f in filtered: sev = f.get('severity', '').lower() results.append({ 'level': 'error' if sev in ['high', 'critical'] else 'warning', 'message': {'text': f.get('message', '')}, 'ruleId': f.get('id', ''), 'locations': [{'physicalLocation': { 'artifactLocation': {'uri': rel_path(f.get('path', ''))}, 'region': { 'startLine': f.get('line', 0), 'startColumn': f.get('col', 0) } }}] }) sarif = { 'version': '2.1.0', '\$schema': 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json', 'runs': [{'results': results, 'tool': {'driver': {'name': 'nyx', 'version': '0.7.0', 'rules': []}}}] } with open('nyx-results.sarif', 'w') as f: json.dump(sarif, f, indent=2) should_fail = any( severity_order.index(f.get('severity','low').lower()) >= fail_idx for f in filtered if f.get('severity','low').lower() in severity_order ) exit(1 if should_fail else 0) " env: GITHUB_WORKSPACE: ${{ github.workspace }} NYX_FAIL_ON: ${{ inputs.fail_on }} continue-on-error: true - name: Post findings as PR comment if: steps.nyx.outcome == 'failure' shell: bash run: | FINDINGS=$(python3 -c " import json, sys with open('nyx-results.sarif') as f: data = json.load(f) results = data.get('runs', [{}])[0].get('results', []) lines = [f'## 🔴 NYX found {len(results)} issue(s)\n'] for r in results: level = r.get('level', '?') msg = r.get('message', {}).get('text', '?') rule = r.get('ruleId', '?') loc = r.get('locations', [{}])[0].get('physicalLocation', {}) path = loc.get('artifactLocation', {}).get('uri', '?') line = loc.get('region', {}).get('startLine', '?') col = loc.get('region', {}).get('startColumn', '?') lines.append(f'- **{level.upper()}** \`{path}:{line}:{col}\` [{rule}] — {msg}') print('\n'.join(lines)) ") curl -sf -X POST \ -H "Authorization: token ${{ inputs.forgejo_push_token }}" \ -H "Content-Type: application/json" \ "https://bitfreedom.net/code/api/v1/repos/${{ inputs.repository }}/issues/${{ inputs.pr_number }}/comments" \ -d "{\"body\": $(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$FINDINGS")}" - name: Fail if findings found if: steps.nyx.outcome == 'failure' shell: bash run: exit 1