about summary refs log tree commit diff
diff options
context:
space:
mode:
authorsterni <sternenseemann@systemli.org>2021-10-10T19·31+0200
committersterni <sternenseemann@systemli.org>2021-10-12T14·15+0000
commit3a2fd6e275580616b86190c9959654521760abe4 (patch)
tree849b7d11d789872f875113ff4da935d8a2b36de6
parent14282370e9519bb916da650c311f8f90ce73ce82 (diff)
feat(nixpkgs-crate-holes): report vulnerable crates in cargoDeps r/2969
nixpkgs-crate-holes can build a markdown report detailing all vulnerable
crates pinned in cargoDeps vendors in nixpkgs according to RustSec's
advisory db. This report is intended to be pasted into a GitHub issue.

The report is produced by a derivation and can be obtained like this:

    nix-build -A users.sterni.nixpkgs-crate-holes.full \
      --argstr nixpkgsPath /path/to/nixpkgs

Example output: https://gist.github.com/sternenseemann/27509eece93d6eff35cd4b8ce75423b5

Additionally, you can obtain a more verbose report for a single
attribute of nixpkgs, in HTML format since we just reuse the command
line output of cargo-audit and convert it to HTML using ansi2html:

    nix-build -A users.sterni.nixpkgs-crate-holes.single \
      --argstr nixpkgsPath /path/to/nixpkgs --argstr attr ripgrep

Change-Id: Ic1c029ab67770fc41ba521b2acb798628357f9b2
Reviewed-on: https://cl.tvl.fyi/c/depot/+/3715
Tested-by: BuildkiteCI
Reviewed-by: sterni <sternenseemann@systemli.org>
-rw-r--r--users/sterni/nixpkgs-crate-holes/default.nix267
-rw-r--r--users/sterni/nixpkgs-crate-holes/format-audit-result.jq59
2 files changed, 326 insertions, 0 deletions
diff --git a/users/sterni/nixpkgs-crate-holes/default.nix b/users/sterni/nixpkgs-crate-holes/default.nix
new file mode 100644
index 0000000000..9ca72e5463
--- /dev/null
+++ b/users/sterni/nixpkgs-crate-holes/default.nix
@@ -0,0 +1,267 @@
+{ depot, pkgs, lib, ... }:
+
+let
+  # dependency imports
+
+  inherit (depot.nix) getBins;
+  inherit (depot.third_party) rustsec-advisory-db;
+
+  bins = getBins pkgs.jq [
+    "jq"
+  ] // getBins pkgs.coreutils [
+    "cat"
+    "printf"
+    "tee"
+    "test"
+    "wc"
+  ] // getBins pkgs.gnugrep [
+    "grep"
+  ] // getBins pkgs.cargo-audit [
+    "cargo-audit"
+  ] // getBins pkgs.ansi2html [
+    "ansi2html"
+  ] // {
+    eprintf = depot.tools.eprintf;
+  };
+
+  # buildRustPackage handling
+
+  /* Predicate by which we identify rust packages we are interested in,
+     i. e. built using `buildRustPackage`.
+
+     Type :: drv -> bool
+  */
+  isRustPackage = v: v ? cargoDeps;
+
+  /* Takes a buildRustPackage derivation and returns a derivation which
+     builds extracts the `Cargo.lock` of its `cargoDeps` derivation or
+     `null` if it has none.
+
+     Type: drv -> option<drv>
+  */
+  # TODO(sterni): support cargoVendorDir?
+  extractCargoLock = drv:
+    if !(drv ? cargoDeps.outPath)
+    then null
+    else pkgs.runCommandNoCC "${drv.name}-Cargo.lock" {} ''
+      if test -d "${drv.cargoDeps}"; then
+        cp "${drv.cargoDeps}/Cargo.lock" "$out"
+      fi
+
+      if test -f "${drv.cargoDeps}"; then
+        tar -xO \
+          --no-wildcards-match-slash --wildcards \
+          -f "${drv.cargoDeps}" \
+          '*/Cargo.lock' \
+          > "$out"
+      fi
+    '';
+
+  # nixpkgs traversal
+
+  # Condition for us to recurse: Either at top-level or recurseForDerivation.
+  recurseInto = path: x: path == [] ||
+    (lib.isAttrs x && (x.recurseForDerivations or false));
+
+  # Returns the value or false if an eval error occurs.
+  tryEvalOrFalse = v: (builtins.tryEval v).value;
+
+  /* Traverses nixpkgs as instructed by `recurseInto` and collects
+     the attribute and lockfile derivation of every rust package it
+     encounters into a list.
+
+     Type :: attrs
+          -> list {
+               attr :: list<str>;
+               lock :: option<drv>;
+               maintainers :: list<maintainer>;
+             }
+  */
+  allLockFiles =
+    let
+      go = path: x:
+        let
+          isDrv = tryEvalOrFalse (lib.isDerivation x);
+          doRec = tryEvalOrFalse (recurseInto path x);
+          isRust = tryEvalOrFalse (isRustPackage x);
+        in
+          if doRec then lib.concatLists (
+            lib.mapAttrsToList (n: go (path ++ [ n ])) x
+          ) else if isDrv && isRust then [
+            {
+              attr = path;
+              lock = extractCargoLock x;
+              maintainers = x.meta.maintainers or [];
+            }
+          ] else [];
+    in go [];
+
+  # Report generation and formatting
+
+  reportFor = { attr, lock, ... }: let
+    # naïve attribute path to Nix syntax conversion
+    strAttr = lib.concatStringsSep "." attr;
+  in
+    if lock == null
+    then pkgs.emptyFile
+    else depot.nix.runExecline "${strAttr}-vulnerability-report" {} [
+      "pipeline" [
+        bins.cargo-audit
+        "audit" "--json"
+        "-n" "--db" rustsec-advisory-db
+        "-f" lock
+      ]
+      "importas" "out" "out"
+      "redirfd" "-w" "1" "$out"
+      bins.jq "-rj" "-f" ./format-audit-result.jq "--arg" "attr" strAttr
+    ];
+
+  # GHMF in issues splits paragraphs on newlines
+  description = lib.concatMapStringsSep "\n\n" (
+    builtins.replaceStrings [ "\n" ] [ " " ]
+  ) [
+    ''
+      The vulnerability report below was generated by
+      [nixpkgs-crate-holes](https://code.tvl.fyi/tree/users/sterni/nixpkgs-crate-holes)
+      which extracts the `Cargo.lock` file of each package in nixpkgs with a
+      `cargoDeps` attribute and passes it to
+      [cargo-audit](https://github.com/RustSec/rustsec/tree/main/cargo-audit)
+      using RustSec's
+      [advisory-db at ${builtins.substring 0 7 rustsec-advisory-db.rev}](https://github.com/RustSec/advisory-db/tree/${rustsec-advisory-db.rev}/).
+    ''
+    ''
+      Feel free to report any problems or suggest improvements (I have an email
+      address on my profile and hang out on Matrix/libera.chat as sterni)!
+      Tick off any reports that have been fixed in the meantime.
+    ''
+    ''
+      Note: A vulnerability in a dependency does not necessarily mean the dependent
+      package is vulnerable, e. g. when a vulnerable function isn't used.
+    ''
+  ];
+
+  runInstructions = ''
+    <details>
+    <summary>
+    Generating Cargo.lock vulnerability reports
+
+    </summary>
+
+    If you have a checkout of [depot](https://code.tvl.fyi/about/), you can generate this report using:
+
+    ```
+    nix-build -A users.sterni.nixpkgs-crate-holes.full \
+      --argstr nixpkgsPath /path/to/nixpkgs
+    ```
+
+    If you want a more detailed report for a single attribute of nixpkgs, use:
+
+    ```
+    nix-build -A users.sterni.nixpkgs-crate-holes.single \
+      --argstr nixpkgsPath /path/to/nixpkgs --arg attr '[ "ripgrep" ]'
+    ```
+
+    </details>
+  '';
+
+  defaultNixpkgsArgs = { allowBroken = false; };
+
+  reportForNixpkgs =
+    { nixpkgsPath
+    , nixpkgsArgs ? defaultNixpkgsArgs
+    }@args:
+
+    let
+      reports = builtins.map reportFor (
+        allLockFiles (import nixpkgsPath nixpkgsArgs)
+      );
+    in
+
+    depot.nix.runExecline "nixpkgs-rust-pkgs-vulnerability-report.md" {
+      stdin = lib.concatMapStrings (report: "${report}\n") reports;
+    } [
+      "importas" "out" "out"
+      "redirfd" "-w" "1" "$out"
+      # Print introduction paragraph for the issue
+      "if" [ bins.printf "%s\n\n" description ]
+      # Print all reports
+      "foreground" [
+        "forstdin" "-E" "report" bins.cat "$report"
+      ]
+      # Print stats at the end (mostly as a gimmick), we already know how many
+      # attributes there are and count the attributes with vulnerability by
+      # finding the number of checkable list entries in the output.
+      "backtick" "-E" "vulnerableCount" [
+        "pipeline" [
+          bins.grep "^- \\[ \\]" "$out"
+        ]
+        bins.wc "-l"
+      ]
+      "if" [
+        bins.printf
+        "\n%s of %s checked attributes have vulnerable dependencies.\n\n"
+        "$vulnerableCount"
+        (toString (builtins.length reports))
+      ]
+      "if" [
+        bins.printf "%s\n\n" runInstructions
+      ]
+    ];
+
+  singleReport =
+    { # Attribute to check: string or list of strings (attr path)
+      attr
+      # Path to importable nixpkgs checkout
+    , nixpkgsPath
+      # Arguments to pass to nixpkgs
+    , nixpkgsArgs ? defaultNixpkgsArgs
+    }:
+
+    let
+      attr' = if builtins.isString attr then [ attr ] else attr;
+      drv = lib.getAttrFromPath attr' (import nixpkgsPath nixpkgsArgs);
+      lockFile = extractCargoLock drv;
+      strAttr = lib.concatStringsSep "." attr';
+    in
+
+    depot.nix.runExecline "${strAttr}-report.html" {} [
+      "importas" "out" "out"
+      "backtick" "-I" "-E" "-N" "report" [
+        bins.cargo-audit "audit"
+        "--quiet"
+        "-n" "--db" rustsec-advisory-db
+        "-f" lockFile
+      ]
+      "pipeline" [
+        "ifte" [
+          bins.printf "%s" "$report"
+        ] [
+          bins.printf "%s\n" "No vulnerabilities found"
+        ]
+        bins.test "-n" "$report"
+      ]
+      "pipeline" [
+        bins.tee "/dev/stderr"
+      ]
+      "redirfd" "-w" "1" "$out"
+      bins.ansi2html
+    ];
+
+in {
+  full = reportForNixpkgs;
+  single = singleReport;
+
+  inherit
+    extractCargoLock
+    allLockFiles
+  ;
+
+  # simple sanity check, doesn't cover everything, but testing the full report
+  # is quite expensive in terms of evaluation.
+  testSingle = singleReport {
+    nixpkgsPath = depot.third_party.nixpkgs.path;
+    attr = [ "ripgrep" ];
+  };
+
+  meta.targets = [ "testSingle" ];
+}
diff --git a/users/sterni/nixpkgs-crate-holes/format-audit-result.jq b/users/sterni/nixpkgs-crate-holes/format-audit-result.jq
new file mode 100644
index 0000000000..c527bc4da9
--- /dev/null
+++ b/users/sterni/nixpkgs-crate-holes/format-audit-result.jq
@@ -0,0 +1,59 @@
+# Link to human-readable advisory info for a given vulnerability
+def link:
+  [ "https://rustsec.org/advisories/", .advisory.id, ".html" ] | add;
+
+# Format a list of version constraints
+def version_list:
+  [ .[] | "`" + . + "`" ] | join("; ");
+
+# show paths to fixing this vulnerability:
+#
+# - if there are patched releases, show them (the version we are using presumably
+#   predates the vulnerability discovery, so we likely want to upgrade to a
+#   patched release).
+# - if there are no patched releases, show the unaffected versions (in case we
+#   want to downgrade).
+# - otherwise we state that no unaffected versions are available at this time.
+#
+# This logic should be useful, but is slightly dumber than cargo-audit's
+# suggestion when using the non-JSON output.
+def patched:
+  if .versions.patched == [] then
+    if .versions.unaffected != [] then
+       "unaffected: " + (.versions.unaffected | version_list)
+    else
+      "no unaffected version available"
+    end
+  else
+    "patched: " + (.versions.patched | version_list)
+  end;
+
+# if the vulnerability has aliases (like CVE-*) emit them in parens
+def aliases:
+  if .advisory.aliases == [] then
+    ""
+  else
+    [ " (", (.advisory.aliases | join(", ")), ")" ] | add
+  end;
+
+# each vulnerability is rendered as a (normal) sublist item
+def format_vulnerability:
+  [ "  - "
+  , .package.name, " ", .package.version, ": "
+  , "[", .advisory.id, "](", link, ")"
+  , aliases
+  , ", ", patched
+  , "\n"
+  ] | add;
+
+# be quiet if no found vulnerabilities, otherwise render a GHFM checklist item
+if .vulnerabilities.found | not then
+  ""
+else
+  ([ "- [ ] "
+   , "`", $attr, "`: "
+   , (.vulnerabilities.count | tostring)
+   , " vulnerabilities in Cargo.lock\n"
+   ] + (.vulnerabilities.list | map(format_vulnerability))
+  ) | add
+end